Reconciler Inventory & Reservations
Fulfillment sometimes leaves a stale inventory reservation
The order says fulfilled, or even cancelled, but the inventory level does not agree. A handful of units at one location are still shown as reserved, and nobody purchased them anymore. Here is why Medusa v2 sometimes fails to clean up a reservation after a line item is fulfilled and a small reconciler that finds only the stale rows and deletes them safely.
Medusa v2 automatically creates a ReservationItem (res_...) linking an inventory_item_id, location_id, and the order's line_item_id whenever a line item is purchased, and the intended lifecycle deletes that row once the line item is fulfilled. GitHub issue #11266 documents that this delete step is not transactionally guaranteed: when a variant has multiple inventory items, or the fulfillment and order completion event handlers race or partially fail, the reservation can be left behind while stock is still counted against it. Because reservations are keyed by line_item_id rather than recomputed from order state, nothing else in Medusa retries the cleanup once it fails. Run a script that pulls terminal orders (completed or canceled, with a matching terminal fulfillment_status), lists the reservations tied to their line items, and deletes only the ones that should have been cleaned up already. Full code, tests, and a dry run guard are below.
The problem in plain words
When a customer buys something, Medusa writes a ReservationItem row that holds stock against that specific line item, inventory item, and location. That is correct and expected. The row is meant to be temporary. Once the order is fulfilled, the create-fulfillment workflow is supposed to delete the reservation, because the stock has now actually left the building and does not need to be held anymore, only subtracted.
The trouble is that delete step is not wrapped in the same transaction as the rest of fulfillment. A variant can be backed by more than one inventory item, which means more than one reservation to clean up per line item. Event handlers for fulfillment and order completion can also race each other or fail partway through. When either of those happens, some or all of the ReservationItem rows for that order's line items survive, even though the stock was already subtracted and allocated. Nothing in Medusa notices the row is now orphaned, because reservations are keyed by line_item_id, not recomputed from the order's current state. The reservation just sits there, permanently holding reserved_quantity against an order that is done.
Why it happens
Reservations are created eagerly, at the moment of purchase, and cleaned up by one narrow step that has to run at exactly the right time. A few common ways stores end up with stale rows:
- A variant is backed by more than one inventory item, so one line item requires deleting more than one
ReservationItem. If the workflow errors out after deleting only the first, the rest are orphaned. - The fulfillment event handler and the order completion event handler race each other, and whichever one is supposed to run the reservation cleanup either runs twice, harmlessly, or does not run at all.
- An order is cancelled instead of fulfilled, and the cancellation path does not reliably fire the same reservation cleanup that a normal fulfillment would have triggered. GitHub issue #11703 documents a related gap where cancelling a fulfillment does not even recreate the first reservation correctly.
- A partial failure anywhere in the create-fulfillment workflow leaves the workflow in a state where later steps, including reservation deletion, never execute, but earlier steps like stock allocation already committed.
Nothing re-checks or retries this cleanup later. ReservationItem rows are keyed by line_item_id, not recomputed from the order's or fulfillment's current state, so once the delete step is missed, the row is permanently stuck, silently shrinking available stock at that location. See the citations at the end for the exact issue and docs.
A reservation left over after fulfillment is only safe to delete once you know, with certainty, that the order it belongs to is truly finished. That means checking both the order's overall status and its fulfillment status, not just one of them. An order can be completed but the check should still confirm fulfillment_status reads fulfilled, delivered, or canceled, because a reservation tied to any order that is still active, pending, or in progress must be left alone. Deleting that reservation too early would let a real order oversell.
The fix, as a flow
We do not touch active fulfillment. The job pulls closed orders, collects their line item ids, lists the reservations tied to those line items, runs a pure decision function that only flags a reservation stale when its order's status and fulfillment status are both terminal, and only then deletes it.
Build it step by step
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.
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
// 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
Pull closed and cancelled orders with their line items
Ask for orders whose status is completed or canceled, and read back the fields the decision needs: the id, display id, status, fulfillment status, and every line item. Paginate with offset and limit, stopping once the offset reaches the reported count.
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_closed_orders(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"status[]": ["completed", "canceled"],
"fields": "id,display_id,status,fulfillment_status,*items",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
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)) {
if (Array.isArray(value)) {
for (const v of value) url.searchParams.append(key, v);
} else {
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 listClosedOrders(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
"status[]": ["completed", "canceled"],
fields: "id,display_id,status,fulfillment_status,*items",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
List the reservations tied to those orders' line items
Batch the collected line item ids into the line_item_id filter on /admin/reservations. This route accepts an array, so a single call, or a small number of chunked calls for a very large backlog, returns every reservation still attached to those orders.
def chunk(items, size):
for i in range(0, len(items), size):
yield items[i:i + size]
def list_reservations_for_line_items(token, line_item_ids):
reservations = []
for batch in chunk(line_item_ids, 100):
data = admin_get(token, "/admin/reservations", {
"line_item_id[]": batch,
"fields": "id,line_item_id,inventory_item_id,location_id,quantity,created_at",
"limit": 200,
})
reservations.extend(data["reservations"])
return reservations
function chunk(items, size) {
const out = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
async function listReservationsForLineItems(token, lineItemIds) {
const reservations = [];
for (const batch of chunk(lineItemIds, 100)) {
const data = await adminGet(token, "/admin/reservations", {
"line_item_id[]": batch,
fields: "id,line_item_id,inventory_item_id,location_id,quantity,created_at",
limit: 200,
});
reservations.push(...data.reservations);
}
return reservations;
}
Decide, with one pure function
Keep the decision in its own function that takes the list of orders and the list of reservations and returns only the matches. It builds a lookup from line_item_id to the order that owns it, then keeps a reservation only when that order's status is in {"completed", "canceled"} and its fulfillment status is in {"fulfilled", "delivered", "canceled"}. It never touches the network, so it is trivial to unit test with fixture arrays.
TERMINAL_ORDER_STATUSES = {"completed", "canceled"}
TERMINAL_FULFILLMENT_STATUSES = {"fulfilled", "delivered", "canceled"}
def find_stale_reservations(orders, reservations):
line_item_to_order = {}
for order in orders:
for item in order.get("items") or []:
line_item_to_order[item["id"]] = order
stale = []
for reservation in reservations:
line_item_id = reservation.get("line_item_id")
if not line_item_id:
continue
order = line_item_to_order.get(line_item_id)
if order is None:
continue
if order.get("status") not in TERMINAL_ORDER_STATUSES:
continue
if order.get("fulfillment_status") not in TERMINAL_FULFILLMENT_STATUSES:
continue
stale.append({
"reservation_id": reservation["id"],
"order_id": order["id"],
"line_item_id": line_item_id,
"quantity": reservation["quantity"],
})
return stale
const TERMINAL_ORDER_STATUSES = new Set(["completed", "canceled"]);
const TERMINAL_FULFILLMENT_STATUSES = new Set(["fulfilled", "delivered", "canceled"]);
export function findStaleReservations(orders, reservations) {
const lineItemToOrder = new Map();
for (const order of orders) {
for (const item of order.items || []) {
lineItemToOrder.set(item.id, order);
}
}
const stale = [];
for (const reservation of reservations) {
const lineItemId = reservation.line_item_id;
if (!lineItemId) continue;
const order = lineItemToOrder.get(lineItemId);
if (!order) continue;
if (!TERMINAL_ORDER_STATUSES.has(order.status)) continue;
if (!TERMINAL_FULFILLMENT_STATUSES.has(order.fulfillment_status)) continue;
stale.push({
reservation_id: reservation.id,
order_id: order.id,
line_item_id: lineItemId,
quantity: reservation.quantity,
});
}
return stale;
}
Delete only the stale reservations
For each match, call DELETE /admin/reservations/{reservation_id}, the same route the admin dashboard's Delete reservation action uses. Medusa's inventory level counters recompute automatically off the ReservationItem table once the row is gone, so no separate location level PATCH is needed. Log every deletion, including the order id, for audit.
def delete_reservation(token, reservation_id):
r = requests.delete(
f"{BACKEND_URL}/admin/reservations/{reservation_id}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()
async function deleteReservation(token, reservationId) {
const res = await fetch(`${BACKEND_URL}/admin/reservations/${reservationId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Medusa ${res.status} on DELETE /admin/reservations/${reservationId}`);
return res.json();
}
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 prints a report of every stale reservation it found, with the reservation id, order id, line item id, inventory item id, location id, and quantity. Read the report, agree with it, then switch it off to let it delete. Run it as a scheduled reconciler, for example nightly.
Always start with DRY_RUN=true, and never relax the requirement that both the order status and the fulfillment status be terminal before deleting. Deleting a reservation is irreversible, and misclassifying an in-progress order as done would let it oversell.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, logs a full report before writing anything, respects the dry run flag, and is safe to run again and again because it only ever deletes a reservation whose owning order is fully terminal on both status and fulfillment status.
"""Find and delete Medusa inventory reservations left over after fulfillment.
Medusa v2 creates a ReservationItem linking an inventory_item_id, location_id,
and the order's line_item_id whenever a line item is purchased. The intended
lifecycle deletes that row once the line item is fulfilled, but the delete step
is not transactionally guaranteed. When a variant has multiple inventory items,
or the fulfillment and order completion handlers race or partially fail, the
reservation can survive an order that is already completed or canceled. This
lists closed orders, resolves the reservations tied to their line items, and
deletes only the ones whose order status and fulfillment status are both
terminal.
Run as a scheduled reconciler. Safe to run again and again.
Guide: https://www.allanninal.dev/medusa/stale-reservation-after-fulfillment/
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_stale_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"
TERMINAL_ORDER_STATUSES = {"completed", "canceled"}
TERMINAL_FULFILLMENT_STATUSES = {"fulfilled", "delivered", "canceled"}
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 find_stale_reservations(orders, reservations):
"""Pure decision function. No I/O.
orders: [{"id": str, "status": str, "fulfillment_status": str, "items": [{"id": str}]}]
reservations: [{"id": str, "line_item_id": str | None, "quantity": int, ...}]
Returns a list of {"reservation_id", "order_id", "line_item_id", "quantity"}
for every reservation whose owning order is completed or canceled AND whose
fulfillment_status is fulfilled, delivered, or canceled.
"""
line_item_to_order = {}
for order in orders:
for item in order.get("items") or []:
line_item_to_order[item["id"]] = order
stale = []
for reservation in reservations:
line_item_id = reservation.get("line_item_id")
if not line_item_id:
continue
order = line_item_to_order.get(line_item_id)
if order is None:
continue
if order.get("status") not in TERMINAL_ORDER_STATUSES:
continue
if order.get("fulfillment_status") not in TERMINAL_FULFILLMENT_STATUSES:
continue
stale.append({
"reservation_id": reservation["id"],
"order_id": order["id"],
"line_item_id": line_item_id,
"quantity": reservation["quantity"],
})
return stale
def chunk(items, size):
for i in range(0, len(items), size):
yield items[i:i + size]
def list_closed_orders(token):
orders = []
offset = 0
limit = 100
while True:
data = admin_get(token, "/admin/orders", {
"status[]": ["completed", "canceled"],
"fields": "id,display_id,status,fulfillment_status,*items",
"limit": limit,
"offset": offset,
})
orders.extend(data["orders"])
offset += limit
if offset >= data["count"]:
return orders
def list_reservations_for_line_items(token, line_item_ids):
reservations = []
for batch in chunk(line_item_ids, 100):
if not batch:
continue
data = admin_get(token, "/admin/reservations", {
"line_item_id[]": batch,
"fields": "id,line_item_id,inventory_item_id,location_id,quantity,created_at",
"limit": 200,
})
reservations.extend(data["reservations"])
return reservations
def run():
token = get_admin_token()
orders = list_closed_orders(token)
line_item_ids = [item["id"] for order in orders for item in (order.get("items") or [])]
reservations = list_reservations_for_line_items(token, line_item_ids)
matches = find_stale_reservations(orders, reservations)
for match in matches:
log.warning(
"Stale reservation %s on order %s, line_item %s, quantity %s. %s",
match["reservation_id"], match["order_id"], match["line_item_id"], match["quantity"],
"Would delete" if DRY_RUN else "Deleting",
)
if not DRY_RUN:
admin_delete(token, f"/admin/reservations/{match['reservation_id']}")
log.info("Done. %d stale reservation(s) %s.", len(matches), "found" if DRY_RUN else "deleted")
if __name__ == "__main__":
run()
/**
* Find and delete Medusa inventory reservations left over after fulfillment.
*
* Medusa v2 creates a ReservationItem linking an inventory_item_id, location_id,
* and the order's line_item_id whenever a line item is purchased. The intended
* lifecycle deletes that row once the line item is fulfilled, but the delete step
* is not transactionally guaranteed. When a variant has multiple inventory items,
* or the fulfillment and order completion handlers race or partially fail, the
* reservation can survive an order that is already completed or canceled. This
* lists closed orders, resolves the reservations tied to their line items, and
* deletes only the ones whose order status and fulfillment status are both
* terminal.
* Run as a scheduled reconciler. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/medusa/stale-reservation-after-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 TERMINAL_ORDER_STATUSES = new Set(["completed", "canceled"]);
const TERMINAL_FULFILLMENT_STATUSES = new Set(["fulfilled", "delivered", "canceled"]);
/**
* Pure decision function. No I/O.
*
* @param {{ id: string, status: string, fulfillment_status: string, items: { id: string }[] }[]} orders
* @param {{ id: string, line_item_id: string | null, quantity: number }[]} reservations
* @returns {{ reservation_id: string, order_id: string, line_item_id: string, quantity: number }[]}
*
* Returns one entry for every reservation whose owning order is completed or
* canceled AND whose fulfillment_status is fulfilled, delivered, or canceled.
*/
export function findStaleReservations(orders, reservations) {
const lineItemToOrder = new Map();
for (const order of orders) {
for (const item of order.items || []) {
lineItemToOrder.set(item.id, order);
}
}
const stale = [];
for (const reservation of reservations) {
const lineItemId = reservation.line_item_id;
if (!lineItemId) continue;
const order = lineItemToOrder.get(lineItemId);
if (!order) continue;
if (!TERMINAL_ORDER_STATUSES.has(order.status)) continue;
if (!TERMINAL_FULFILLMENT_STATUSES.has(order.fulfillment_status)) continue;
stale.push({
reservation_id: reservation.id,
order_id: order.id,
line_item_id: lineItemId,
quantity: reservation.quantity,
});
}
return stale;
}
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)) {
if (Array.isArray(value)) {
for (const v of value) url.searchParams.append(key, v);
} else {
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();
}
function chunk(items, size) {
const out = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
async function listClosedOrders(token) {
const orders = [];
let offset = 0;
const limit = 100;
while (true) {
const data = await adminGet(token, "/admin/orders", {
"status[]": ["completed", "canceled"],
fields: "id,display_id,status,fulfillment_status,*items",
limit,
offset,
});
orders.push(...data.orders);
offset += limit;
if (offset >= data.count) return orders;
}
}
async function listReservationsForLineItems(token, lineItemIds) {
const reservations = [];
for (const batch of chunk(lineItemIds, 100)) {
if (!batch.length) continue;
const data = await adminGet(token, "/admin/reservations", {
"line_item_id[]": batch,
fields: "id,line_item_id,inventory_item_id,location_id,quantity,created_at",
limit: 200,
});
reservations.push(...data.reservations);
}
return reservations;
}
export async function run() {
const token = await getAdminToken();
const orders = await listClosedOrders(token);
const lineItemIds = orders.flatMap((order) => (order.items || []).map((item) => item.id));
const reservations = await listReservationsForLineItems(token, lineItemIds);
const matches = findStaleReservations(orders, reservations);
for (const match of matches) {
console.warn(
`Stale reservation ${match.reservation_id} on order ${match.order_id}, line_item ${match.line_item_id}, quantity ${match.quantity}. ${DRY_RUN ? "Would delete" : "Deleting"}`
);
if (!DRY_RUN) {
await adminDelete(token, `/admin/reservations/${match.reservation_id}`);
}
}
console.log(`Done. ${matches.length} stale reservation(s) ${DRY_RUN ? "found" : "deleted"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
find_stale_reservations 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 fixture arrays of orders and reservations and checks the answer.
from find_stale_reservations import find_stale_reservations
def order(**over):
base = {
"id": "order_1",
"status": "completed",
"fulfillment_status": "fulfilled",
"items": [{"id": "item_1"}],
}
base.update(over)
return base
def reservation(**over):
base = {"id": "res_1", "line_item_id": "item_1", "quantity": 2}
base.update(over)
return base
def test_flags_reservation_when_order_completed_and_fulfilled():
result = find_stale_reservations([order()], [reservation()])
assert result == [
{"reservation_id": "res_1", "order_id": "order_1", "line_item_id": "item_1", "quantity": 2}
]
def test_flags_reservation_when_order_canceled_and_fulfillment_canceled():
o = order(status="canceled", fulfillment_status="canceled")
result = find_stale_reservations([o], [reservation()])
assert len(result) == 1
def test_keeps_reservation_when_order_still_in_progress():
o = order(status="pending", fulfillment_status="not_fulfilled")
result = find_stale_reservations([o], [reservation()])
assert result == []
def test_keeps_reservation_when_order_completed_but_fulfillment_not_terminal():
o = order(status="completed", fulfillment_status="partially_fulfilled")
result = find_stale_reservations([o], [reservation()])
assert result == []
def test_keeps_reservation_with_no_matching_line_item():
result = find_stale_reservations([order()], [reservation(line_item_id="item_unknown")])
assert result == []
def test_keeps_reservation_with_no_line_item_id():
result = find_stale_reservations([order()], [reservation(line_item_id=None)])
assert result == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { findStaleReservations } from "./find-stale-reservations.js";
const order = (over = {}) => ({
id: "order_1",
status: "completed",
fulfillment_status: "fulfilled",
items: [{ id: "item_1" }],
...over,
});
const reservation = (over = {}) => ({
id: "res_1",
line_item_id: "item_1",
quantity: 2,
...over,
});
test("flags reservation when order completed and fulfilled", () => {
const result = findStaleReservations([order()], [reservation()]);
assert.deepEqual(result, [
{ reservation_id: "res_1", order_id: "order_1", line_item_id: "item_1", quantity: 2 },
]);
});
test("flags reservation when order canceled and fulfillment canceled", () => {
const o = order({ status: "canceled", fulfillment_status: "canceled" });
const result = findStaleReservations([o], [reservation()]);
assert.equal(result.length, 1);
});
test("keeps reservation when order still in progress", () => {
const o = order({ status: "pending", fulfillment_status: "not_fulfilled" });
const result = findStaleReservations([o], [reservation()]);
assert.deepEqual(result, []);
});
test("keeps reservation when order completed but fulfillment not terminal", () => {
const o = order({ status: "completed", fulfillment_status: "partially_fulfilled" });
const result = findStaleReservations([o], [reservation()]);
assert.deepEqual(result, []);
});
test("keeps reservation with no matching line item", () => {
const result = findStaleReservations([order()], [reservation({ line_item_id: "item_unknown" })]);
assert.deepEqual(result, []);
});
test("keeps reservation with no line_item_id", () => {
const result = findStaleReservations([order()], [reservation({ line_item_id: null })]);
assert.deepEqual(result, []);
});
Case studies
The variant split across two inventory items forgot half its cleanup
A furniture store stocked one variant across two inventory items, a main warehouse and an overflow location, because a single order could pull from either. When an order fulfilled, the workflow deleted the reservation tied to the main warehouse's inventory item, but the overflow location's reservation was left standing after a partial failure downstream.
Running the reconciler in dry run surfaced every one of those overflow reservations, all tied to orders that were fully completed and fulfilled. The team reviewed the report, ran it for real, and the overflow location's available stock came back within minutes.
The cancelled order that kept holding stock for months
A customer's order was cancelled after a chargeback, and the order and its fulfillment both correctly flipped to canceled. But the reservation cleanup that should have run alongside the cancellation never fired, so the item's reserved_quantity at that location stayed inflated for months without anyone noticing until stock counts stopped matching.
The reconciler matched the reservation's line item back to that now fully canceled order, flagged it stale, and a single DELETE /admin/reservations/{id} call brought the count back in line the same day it was found.
After this runs as a scheduled reconciler, reserved quantity always reflects stock that a genuinely active order still needs. Reservations left behind by a partial fulfillment failure or a cancellation that skipped its cleanup step get cleared automatically, with a full audit log of every deletion. In-progress orders are never touched, because the order status check and the fulfillment status check both have to agree before anything is removed.
FAQ
Why does a completed Medusa order still hold an inventory reservation?
Medusa v2 creates a ReservationItem for a line item when it is purchased, and the intended lifecycle is to delete that row once the line item is fulfilled. The delete step is not transactionally guaranteed against the rest of the create-fulfillment workflow, so when a variant has multiple inventory items or the fulfillment and order completion handlers race or partially fail, the reservation can survive even though the order is complete or cancelled.
Is it safe to delete Medusa reservations left over after fulfillment with a script?
Yes, when the script only deletes a reservation after confirming its line_item_id belongs to an order whose status is completed or canceled and whose fulfillment_status is fulfilled, delivered, or canceled. Requiring both the order status and the fulfillment status to be terminal before deleting keeps the script from ever touching a reservation an in-progress order still needs.
What happens to reserved_quantity when a stale reservation is deleted?
Medusa's inventory level counters recompute automatically off the ReservationItem table. Once the stale row is deleted through DELETE /admin/reservations/{id}, reserved_quantity at that location drops by the reservation's quantity and available_quantity goes back up, with no separate location level PATCH needed.
Related field notes
Citations
On the problem:
- Order fulfillment is not deleting inventory item reservations sometimes. Medusa GitHub Issue #11266. github.com/medusajs/medusa/issues/11266
- Medusa Admin User Guide: Manage Reservations. docs.medusajs.com/user-guide/inventory/reservations
- Cancelling fulfillment does not recreate the first reservation. Medusa GitHub Issue #11703. github.com/medusajs/medusa/issues/11703
On the solution:
- Medusa Documentation: ReservationItem, Inventory Module Data Models. docs.medusajs.com/resources/references/inventory-next/models/ReservationItem
- Medusa Documentation: Inventory Module in Medusa Flows. docs.medusajs.com/resources/commerce-modules/inventory/inventory-in-flows
- Medusa V2 Admin API Reference. docs.medusajs.com/api/admin
Stuck on a tricky one?
If you have a problem in Medusa 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.
Did this free up your stock?
If this saved you from a phantom out of stock item or a stock count that would not add up, 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