Diagnostic Checkout & Stock Reservation
Concurrent checkouts oversell the same stock
Two customers hit checkout on the last unit of a SKU within a second of each other. Both checkouts finish. Both orders look normal. Then the warehouse counts stock and finds one more allocated unit than actually exists. This is a known race in how Saleor checks stock and allocates it, and it is not something you patch by hand, it is something you audit for and triage with a script.
Saleor allocates stock during checkoutComplete by comparing Stock.quantity against existing Allocation rows for a variant and warehouse. When two checkouts for the same SKU race through that check-then-allocate sequence close together, both can read an available quantity that has not yet accounted for the other's in-flight allocation, so both complete and the sum of allocated quantity ends up higher than the stock on hand (see saleor/saleor#543). Run a small Python or Node.js script that pages through variants and warehouses, compares quantityAllocated to quantity per Stock row, and reports every oversold pair plus the affected order IDs. It never rewrites stock or cancels an order on its own. Full code, tests, and a dry run guarded repair suggestion are below.
The problem in plain words
Saleor decides whether a checkout can complete by looking at how much of a variant is currently available in a warehouse: the stock on hand minus whatever is already allocated to other orders. If enough is free, the checkout is allowed to finish, and a new Allocation row is written to claim those units.
That read-then-write sequence is not a single atomic step from the customer's point of view. If two checkouts for the same variant and warehouse reach the "is there enough stock" check at nearly the same moment, both can see the same available number, both can conclude there is enough, and both can proceed to allocate. The result is two allocations that together claim more units than the warehouse actually has. Turning on stock reservations narrows the window, since a reservation holds the unit for the checkout in progress, but reservations expire on a timer and only cover the in-progress window, so a race right at completion time or right at reservation expiry can still slip through, especially when multiple warehouses feed one channel's available quantity.
Why it happens
- Saleor allocates stock during
checkoutComplete, comparingStock.quantityagainst existingAllocationrows for the variant and warehouse at that moment, not against a lock that blocks a second concurrent read. This exact race is documented in saleor/saleor#543. - Optional stock reservations, described in Saleor's stock reservation docs, hold a unit provisionally while a checkout is in progress, but reservations expire on a timer. A race at completion time, or right at the moment a reservation lapses, is not covered.
- The checkout lifecycle, per Saleor's own checkout lifecycle documentation, has multiple steps between creating a checkout and completing it, and traffic spikes on high demand SKUs push more of those steps to overlap in time.
- When a channel pulls availability from more than one warehouse, the combined available quantity read by a checkout can be stale relative to allocations another checkout is writing to a different warehouse in the same channel, widening the same class of race.
This is not a bug you can catch by reading code once. It shows up under load, on popular low-stock SKUs, and it leaves no error in either checkout: both customers get a normal confirmation. The only way to know it happened is to look at the stock rows afterward and see that allocated is higher than on hand.
You cannot safely undo an oversell by script. Once two orders exist and one of them has to lose its unit, that is a decision about which customer to disappoint, whether to backorder, expedite a restock, or split a fulfillment. That is a business and support call, not something a script should guess at. So the right tool here is a detector: page through stock, flag every variant and warehouse where quantityAllocated > quantity, and hand the list of affected orders to a person, along with a proposed correction that is never applied automatically.
The fix, as a flow
The script does not touch the checkout flow at all. It runs after the fact, on a schedule, and reads the current state of stock and orders. For each variant and warehouse pair it checks whether allocated exceeds on hand. When it finds an oversold pair, it reports the delta, cross-checks against unfulfilled order allocations for a second signal, and prints a suggested stockBulkUpdate payload as a proposal only, guarded by DRY_RUN, next to the list of order IDs a human needs to triage.
Build it step by step
Get an app token with read access to stock and orders
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read products, stock, and orders. Use the resulting app token as a Bearer token, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.
pip install requests
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true" # start safe, this script never writes without it off
// Node 18+ has fetch built in, no dependencies needed
export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export SALEOR_CHANNEL="default-channel"
export DRY_RUN="true" // start safe, this script never writes without it off
Talk to the Saleor GraphQL API
Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.
import os, requests
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
const API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
Page through variants and read their stock per warehouse
Ask for productVariants on the channel you care about, and read back each variant's stocks: the warehouse, the on-hand quantity, and the quantityAllocated. Page with a cursor so the job handles a full catalog, and flatten every stock row into one flat snapshot the decision function can work with.
VARIANTS_QUERY = """
query($channel: String!, $cursor: String) {
productVariants(channel: $channel, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
stocks { warehouse { id slug } quantity quantityAllocated }
}
}
}
}"""
def stock_snapshot(channel):
cursor = None
rows = []
while True:
data = gql(VARIANTS_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
for edge in data["edges"]:
node = edge["node"]
for stock in node["stocks"]:
rows.append({
"variantId": node["id"],
"sku": node["sku"],
"warehouseId": stock["warehouse"]["id"],
"warehouseSlug": stock["warehouse"]["slug"],
"quantity": stock["quantity"],
"quantityAllocated": stock["quantityAllocated"],
})
if not data["pageInfo"]["hasNextPage"]:
return rows
cursor = data["pageInfo"]["endCursor"]
const VARIANTS_QUERY = `
query($channel: String!, $cursor: String) {
productVariants(channel: $channel, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
stocks { warehouse { id slug } quantity quantityAllocated }
}
}
}
}`;
async function stockSnapshot(channel) {
let cursor = null;
const rows = [];
while (true) {
const data = (await gql(VARIANTS_QUERY, { channel, cursor })).productVariants;
for (const edge of data.edges) {
const node = edge.node;
for (const stock of node.stocks) {
rows.push({
variantId: node.id,
sku: node.sku,
warehouseId: stock.warehouse.id,
warehouseSlug: stock.warehouse.slug,
quantity: stock.quantity,
quantityAllocated: stock.quantityAllocated,
});
}
}
if (!data.pageInfo.hasNextPage) return rows;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes the already-fetched stock snapshot and returns only the oversold rows. A pure function like this is easy to read and test, which we do later. The rule is simple: compute delta = quantityAllocated - quantity for each row, keep only rows where delta is greater than zero, and sort the worst oversells first.
def find_oversold_stocks(stocks):
oversold = []
for stock in stocks:
delta = stock["quantityAllocated"] - stock["quantity"]
if delta > 0:
oversold.append({
"variantId": stock["variantId"],
"sku": stock["sku"],
"warehouseId": stock["warehouseId"],
"delta": delta,
})
oversold.sort(key=lambda row: row["delta"], reverse=True)
return oversold
export function findOversoldStocks(stocks) {
return stocks
.map((stock) => ({
variantId: stock.variantId,
sku: stock.sku,
warehouseId: stock.warehouseId,
delta: stock.quantityAllocated - stock.quantity,
}))
.filter((row) => row.delta > 0)
.sort((a, b) => b.delta - a.delta);
}
Cross-check against unfulfilled order allocations
The Stock.quantityAllocated field is Saleor's own cache. For defense in depth, also look at the orders that are actually holding an allocation for that variant, by querying orders in UNFULFILLED or PARTIALLY_FULFILLED status and reading each order line's allocations. This gives you the concrete order IDs to hand to a human, not just a number.
ORDERS_WITH_ALLOCATIONS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor,
filter: { status: [UNFULFILLED, PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
lines {
variant { id sku }
allocations { quantity warehouse { id } }
}
}
}
}
}"""
def orders_allocating_variant(variant_id, warehouse_id):
cursor = None
matches = []
while True:
data = gql(ORDERS_WITH_ALLOCATIONS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
order = edge["node"]
for line in order["lines"]:
if not line["variant"] or line["variant"]["id"] != variant_id:
continue
for allocation in line["allocations"]:
if allocation["warehouse"]["id"] == warehouse_id:
matches.append(order["id"])
if not data["pageInfo"]["hasNextPage"]:
return matches
cursor = data["pageInfo"]["endCursor"]
const ORDERS_WITH_ALLOCATIONS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor,
filter: { status: [UNFULFILLED, PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
lines {
variant { id sku }
allocations { quantity warehouse { id } }
}
}
}
}
}`;
async function ordersAllocatingVariant(variantId, warehouseId) {
let cursor = null;
const matches = [];
while (true) {
const data = (await gql(ORDERS_WITH_ALLOCATIONS_QUERY, { cursor })).orders;
for (const edge of data.edges) {
const order = edge.node;
for (const line of order.lines) {
if (!line.variant || line.variant.id !== variantId) continue;
for (const allocation of line.allocations) {
if (allocation.warehouse.id === warehouseId) matches.push(order.id);
}
}
}
if (!data.pageInfo.hasNextPage) return matches;
cursor = data.pageInfo.endCursor;
}
}
Report a suggestion, never write it automatically
Wire it together. For every oversold pair, print the delta, the affected order IDs, and a suggested stockBulkUpdate payload that sets quantity to a corrected on-hand count you would get from a physical recount. That payload is printed as a proposal even when DRY_RUN is off, because deciding the correct on-hand number and which orders lose stock is not something this script can know. It only calls stockBulkUpdate if you explicitly wire in a reviewed, human-approved quantity, which is outside the scope of this report.
This script only reads. It never calls stockBulkUpdate or orderCancel. When it finds an oversell, it prints the suggested repair and the affected order IDs so a person can pick which order gets cancelled, backordered, expedited, or split, rather than a script guessing.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, pages through stock and orders, flags every oversold variant and warehouse pair, and prints a dry run guarded suggestion. It never mutates stock or cancels an order.
"""Find Saleor variant and warehouse pairs where quantityAllocated exceeds quantity.
Concurrent checkouts can race through Saleor's check-then-allocate stock flow
(saleor/saleor#543) and both complete, leaving more stock allocated than exists.
This script never rewrites stock or cancels an order. It reports the oversold
pairs, the affected order IDs, and a suggested stockBulkUpdate payload for a
human to 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("find_oversold_stock")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
CHANNEL = os.environ.get("SALEOR_CHANNEL", "default-channel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
VARIANTS_QUERY = """
query($channel: String!, $cursor: String) {
productVariants(channel: $channel, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
stocks { warehouse { id slug } quantity quantityAllocated }
}
}
}
}"""
ORDERS_WITH_ALLOCATIONS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor,
filter: { status: [UNFULFILLED, PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
lines {
variant { id sku }
allocations { quantity warehouse { id } }
}
}
}
}
}"""
def gql(query, variables=None):
r = requests.post(
API_URL,
json={"query": query, "variables": variables or {}},
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def find_oversold_stocks(stocks):
oversold = []
for stock in stocks:
delta = stock["quantityAllocated"] - stock["quantity"]
if delta > 0:
oversold.append({
"variantId": stock["variantId"],
"sku": stock["sku"],
"warehouseId": stock["warehouseId"],
"delta": delta,
})
oversold.sort(key=lambda row: row["delta"], reverse=True)
return oversold
def stock_snapshot(channel):
cursor = None
rows = []
while True:
data = gql(VARIANTS_QUERY, {"channel": channel, "cursor": cursor})["productVariants"]
for edge in data["edges"]:
node = edge["node"]
for stock in node["stocks"]:
rows.append({
"variantId": node["id"],
"sku": node["sku"],
"warehouseId": stock["warehouse"]["id"],
"warehouseSlug": stock["warehouse"]["slug"],
"quantity": stock["quantity"],
"quantityAllocated": stock["quantityAllocated"],
})
if not data["pageInfo"]["hasNextPage"]:
return rows
cursor = data["pageInfo"]["endCursor"]
def orders_allocating_variant(variant_id, warehouse_id):
cursor = None
matches = []
while True:
data = gql(ORDERS_WITH_ALLOCATIONS_QUERY, {"cursor": cursor})["orders"]
for edge in data["edges"]:
order = edge["node"]
for line in order["lines"]:
if not line["variant"] or line["variant"]["id"] != variant_id:
continue
for allocation in line["allocations"]:
if allocation["warehouse"]["id"] == warehouse_id:
matches.append(order["id"])
if not data["pageInfo"]["hasNextPage"]:
return matches
cursor = data["pageInfo"]["endCursor"]
def run():
stocks = stock_snapshot(CHANNEL)
oversold = find_oversold_stocks(stocks)
if not oversold:
log.info("Done. No oversold variant and warehouse pairs found.")
return
for row in oversold:
affected_orders = orders_allocating_variant(row["variantId"], row["warehouseId"])
log.warning(
"OVERSOLD sku=%s variant=%s warehouse=%s delta=%d affected_orders=%s",
row["sku"], row["variantId"], row["warehouseId"], row["delta"], affected_orders,
)
suggested_payload = {
"stocks": [{
"variantId": row["variantId"],
"warehouseId": row["warehouseId"],
"quantity": "",
}],
"errorPolicy": "REJECT_EVERYTHING",
}
log.info(
"Suggested repair (%s, not applied automatically): %s",
"dry run" if DRY_RUN else "human review required",
suggested_payload,
)
log.info("Done. %d oversold variant and warehouse pair(s) reported.", len(oversold))
if __name__ == "__main__":
run()
/**
* Find Saleor variant and warehouse pairs where quantityAllocated exceeds quantity.
*
* Concurrent checkouts can race through Saleor's check-then-allocate stock flow
* (saleor/saleor#543) and both complete, leaving more stock allocated than exists.
* This script never rewrites stock or cancels an order. It reports the oversold
* pairs, the affected order IDs, and a suggested stockBulkUpdate payload for a
* human to review. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/concurrent-checkouts-oversell-stock/
*/
import { pathToFileURL } from "node:url";
const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const CHANNEL = process.env.SALEOR_CHANNEL || "default-channel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function findOversoldStocks(stocks) {
return stocks
.map((stock) => ({
variantId: stock.variantId,
sku: stock.sku,
warehouseId: stock.warehouseId,
delta: stock.quantityAllocated - stock.quantity,
}))
.filter((row) => row.delta > 0)
.sort((a, b) => b.delta - a.delta);
}
async function gql(query, variables = {}) {
const res = await fetch(API_URL, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Saleor ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const VARIANTS_QUERY = `
query($channel: String!, $cursor: String) {
productVariants(channel: $channel, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
sku
stocks { warehouse { id slug } quantity quantityAllocated }
}
}
}
}`;
const ORDERS_WITH_ALLOCATIONS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor,
filter: { status: [UNFULFILLED, PARTIALLY_FULFILLED] }) {
pageInfo { hasNextPage endCursor }
edges {
node {
id
lines {
variant { id sku }
allocations { quantity warehouse { id } }
}
}
}
}
}`;
async function stockSnapshot(channel) {
let cursor = null;
const rows = [];
while (true) {
const data = (await gql(VARIANTS_QUERY, { channel, cursor })).productVariants;
for (const edge of data.edges) {
const node = edge.node;
for (const stock of node.stocks) {
rows.push({
variantId: node.id,
sku: node.sku,
warehouseId: stock.warehouse.id,
warehouseSlug: stock.warehouse.slug,
quantity: stock.quantity,
quantityAllocated: stock.quantityAllocated,
});
}
}
if (!data.pageInfo.hasNextPage) return rows;
cursor = data.pageInfo.endCursor;
}
}
async function ordersAllocatingVariant(variantId, warehouseId) {
let cursor = null;
const matches = [];
while (true) {
const data = (await gql(ORDERS_WITH_ALLOCATIONS_QUERY, { cursor })).orders;
for (const edge of data.edges) {
const order = edge.node;
for (const line of order.lines) {
if (!line.variant || line.variant.id !== variantId) continue;
for (const allocation of line.allocations) {
if (allocation.warehouse.id === warehouseId) matches.push(order.id);
}
}
}
if (!data.pageInfo.hasNextPage) return matches;
cursor = data.pageInfo.endCursor;
}
}
export async function run() {
const stocks = await stockSnapshot(CHANNEL);
const oversold = findOversoldStocks(stocks);
if (oversold.length === 0) {
console.log("Done. No oversold variant and warehouse pairs found.");
return;
}
for (const row of oversold) {
const affectedOrders = await ordersAllocatingVariant(row.variantId, row.warehouseId);
console.warn(
`OVERSOLD sku=${row.sku} variant=${row.variantId} warehouse=${row.warehouseId} delta=${row.delta} affected_orders=${JSON.stringify(affectedOrders)}`
);
const suggestedPayload = {
stocks: [{
variantId: row.variantId,
warehouseId: row.warehouseId,
quantity: "",
}],
errorPolicy: "REJECT_EVERYTHING",
};
console.log(
`Suggested repair (${DRY_RUN ? "dry run" : "human review required"}, not applied automatically): ${JSON.stringify(suggestedPayload)}`
);
}
console.log(`Done. ${oversold.length} oversold variant and warehouse pair(s) reported.`);
}
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 which SKUs get reported as oversold. Because find_oversold_stocks is pure, the test needs no network and no Saleor account. It just feeds in plain stock rows and checks the answer.
from find_oversold_stock import find_oversold_stocks
def stock(**over):
base = {
"variantId": "gid://saleor/ProductVariant/1",
"sku": "SKU-1",
"warehouseId": "gid://saleor/Warehouse/1",
"warehouseSlug": "main",
"quantity": 1,
"quantityAllocated": 1,
}
base.update(over)
return base
def test_no_oversold_when_allocated_equals_quantity():
assert find_oversold_stocks([stock()]) == []
def test_flags_oversold_when_allocated_exceeds_quantity():
result = find_oversold_stocks([stock(quantityAllocated=2)])
assert result == [{
"variantId": "gid://saleor/ProductVariant/1",
"sku": "SKU-1",
"warehouseId": "gid://saleor/Warehouse/1",
"delta": 1,
}]
def test_no_oversold_when_allocated_is_less_than_quantity():
assert find_oversold_stocks([stock(quantity=5, quantityAllocated=3)]) == []
def test_sorted_by_delta_descending():
rows = [
stock(sku="SMALL", quantity=10, quantityAllocated=11),
stock(sku="BIG", quantity=1, quantityAllocated=6),
]
result = find_oversold_stocks(rows)
assert [row["sku"] for row in result] == ["BIG", "SMALL"]
def test_only_oversold_rows_are_returned():
rows = [
stock(sku="OK", quantity=5, quantityAllocated=5),
stock(sku="OVER", quantity=2, quantityAllocated=4),
]
result = find_oversold_stocks(rows)
assert len(result) == 1
assert result[0]["sku"] == "OVER"
assert result[0]["delta"] == 2
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOversoldStocks } from "./find-oversold-stock.js";
const stock = (over = {}) => ({
variantId: "gid://saleor/ProductVariant/1",
sku: "SKU-1",
warehouseId: "gid://saleor/Warehouse/1",
warehouseSlug: "main",
quantity: 1,
quantityAllocated: 1,
...over,
});
test("no oversold when allocated equals quantity", () => {
assert.deepEqual(findOversoldStocks([stock()]), []);
});
test("flags oversold when allocated exceeds quantity", () => {
const result = findOversoldStocks([stock({ quantityAllocated: 2 })]);
assert.deepEqual(result, [{
variantId: "gid://saleor/ProductVariant/1",
sku: "SKU-1",
warehouseId: "gid://saleor/Warehouse/1",
delta: 1,
}]);
});
test("no oversold when allocated is less than quantity", () => {
assert.deepEqual(findOversoldStocks([stock({ quantity: 5, quantityAllocated: 3 })]), []);
});
test("sorted by delta descending", () => {
const rows = [
stock({ sku: "SMALL", quantity: 10, quantityAllocated: 11 }),
stock({ sku: "BIG", quantity: 1, quantityAllocated: 6 }),
];
const result = findOversoldStocks(rows);
assert.deepEqual(result.map((row) => row.sku), ["BIG", "SMALL"]);
});
test("only oversold rows are returned", () => {
const rows = [
stock({ sku: "OK", quantity: 5, quantityAllocated: 5 }),
stock({ sku: "OVER", quantity: 2, quantityAllocated: 4 }),
];
const result = findOversoldStocks(rows);
assert.equal(result.length, 1);
assert.equal(result[0].sku, "OVER");
assert.equal(result[0].delta, 2);
});
Case studies
A drop with three units left sold five
A streetwear brand launched a small drop of a shoe with exactly three pairs left in one warehouse. The launch post drove a burst of traffic in the same ten seconds, and Saleor's checkoutComplete accepted five orders for those three pairs before anyone noticed. Support only found out when the fulfillment team could not pack all the orders.
Running this script right after the launch window surfaced the exact SKU, warehouse, and the five order IDs holding allocations against three units of stock. The team could see the whole mess in one report instead of discovering it order by order at pack time, and decided which two orders to convert to backorders with an apology discount.
Two warehouses feeding one channel double counted the last unit
A home goods store had a single channel fed by two regional warehouses. A popular lamp had one unit left, split as one unit each in two warehouses' combined availability calculation for the channel, but the underlying stock rows were separate. Two checkouts a minute apart each grabbed what looked like the last unit, and both completed against different warehouse stock rows that individually looked fine but the SKU overall was oversold relative to true demand.
The nightly run of this script caught the warehouse pair where allocated stock crept past quantity, and the cross-check against unfulfilled orders gave the ops team the two order IDs before either shipment left the building.
After this runs on a schedule, an oversell on a hot SKU is caught the same day, not discovered when a fulfillment team member cannot find the second unit in the bin. The team gets the exact variant, warehouse, delta, and order IDs to work from, and the actual fix, cancel, backorder, or expedite, stays a human decision made with full information instead of a guess made under pressure.
FAQ
Why do two Saleor checkouts both complete for the same last unit?
Saleor allocates stock during checkoutComplete by comparing Stock.quantity against existing Allocation rows for that variant and warehouse. When two checkouts for the same SKU run this check-then-allocate sequence close together, both can read an available quantity that has not yet accounted for the other checkout's in-flight allocation, so both complete and the sum of allocations ends up higher than the stock on hand.
Does enabling stock reservations stop the oversell?
It narrows the window but does not close it. Stock reservations only cover the time a checkout is in progress and expire on a timer, so a race at completion time or right at reservation expiry can still let more units be allocated than are physically on hand, especially when several warehouses feed the same channel's available quantity.
Should a script automatically cancel one of the oversold orders?
No. Deciding which order loses inventory, whether to cancel it, backorder it, expedite a restock, or split the fulfillment, is a business and customer service call. The safe pattern is to detect and report the oversold variant and warehouse pairs plus the affected order IDs, and leave the stockBulkUpdate or orderCancel call to a human.
Related field notes
Citations
On the problem:
- Concurrent checkouts allocate more stocks to customers than the quantity available. github.com/saleor/saleor/issues/543
- Saleor Commerce Documentation: Stock Reservation. docs.saleor.io/developer/stock/stock-reservation
- Saleor Commerce Documentation: Checkout Lifecycle. docs.saleor.io/developer/checkout/lifecycle
On the solution:
- Saleor Commerce Documentation: Stock Allocation. docs.saleor.io/developer/stock/stock-allocation
- Saleor Commerce Documentation: Stock Overview. docs.saleor.io/developer/stock/overview
- Saleor Commerce Documentation: stockBulkUpdate Mutation. docs.saleor.io/api-reference/products/mutations/stock-bulk-update
Stuck on a tricky one?
If you have a problem in Saleor checkout, stock, channels, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch an oversell for you?
If this saved you from discovering an oversell at pack time, or gave your support team the report they needed, 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