Reconciler Stock & Inventory
Bulk stock update leaves stale or partial rows
You fire one stockBulkUpdate or productVariantStocksUpdate call with a list of warehouse rows and move on, assuming every row landed. Then a customer reports a warehouse still shows the old quantity, or a report shows a variant that never actually changed. The batch call did not fail loudly. It just did not fully agree with what you asked for. Here is why that happens and a script that finds every row that is still stale and repairs only the ones it can safely explain.
Saleor's bulk stock mutations, productVariantStocksUpdate and stockBulkUpdate, take a list of per-warehouse rows in stocks and process the whole batch in one call, but error handling is row-scoped and controlled by an errorPolicy. The default REJECT_EVERYTHING rolls back the entire batch if even one row is bad, such as a warehouse not on the variant's shipping zones, an unknown id, or a duplicate warehouse, and it does not always make that all-or-nothing behavior obvious to the caller. REJECT_FAILED_ROWS and IGNORE_FAILED_ROWS instead persist the good rows and skip the bad ones on purpose. A script that fires one bulk call and assumes every warehouse updated can end up with Stock rows still holding their old quantity, and concurrent writers between the call and a later read can make a row look stale even when the mutation itself actually succeeded for it. Run a small Python or Node.js script that replays the mutation, reads back real stock, diffs intended against actual, and only auto-repairs the rows a genuine failed-row error explains. Full code, tests, and a dry run guard are below.
The problem in plain words
A bulk stock update looks simple from the caller's side. You build a list of {variantId, warehouseId, quantity} rows, some for a hundred warehouses on one variant, some for one warehouse across a hundred variants, send it in one GraphQL call, and expect Saleor to write every row exactly as sent.
What actually happens depends on the errorPolicy, and on what else is touching stock at the same time. With the default policy, one bad row anywhere in the batch, a warehouse that is not assigned to the variant's shipping zones, an id that does not exist anymore, or the same warehouse listed twice, rolls back the whole call, so nothing you intended actually landed even though the request itself came back without a hard error. With a row-tolerant policy, Saleor deliberately keeps the good rows and drops the bad ones, which is correct behavior, but only if your script actually reads the per-row result and treats a skipped row differently from a written one. A script that only checks for a top-level exception will not notice either case, and will report success while some rows are quietly stale.
Why it happens
productVariantStocksUpdatetakes astocks: [StockInput!]!list for one variant, andstockBulkUpdatetakes rows across many variants and warehouses, both processed in a single call, but the mutation's own history shows it has not always fully propagated every row, a gap tracked in saleor/saleor#6479.- The default
errorPolicyisREJECT_EVERYTHING, which rolls back the entire batch the moment any single row fails, for example a warehouse that is not on the variant's shipping zones, a stale or unknown variant or warehouse id, or the same warehouse appearing twice in one call. REJECT_FAILED_ROWSandIGNORE_FAILED_ROWSexist specifically to persist the valid rows and drop the invalid ones on purpose, which is correct, but a caller that does not readerrors,bulkStockErrors, orresults[].errorsper row will not know which rows were actually skipped, and can log a false success.- Concurrent writers between the bulk call and a later read, another admin editing stock, a webhook-driven allocation, or an order fulfillment decrementing quantity, can also make a row look stale versus your intended target even though the mutation itself genuinely succeeded for that row at the time it ran, a distinct but related class of drift also visible in reports like saleor/saleor#5578.
None of this throws a top-level exception in the common case. The call returns, your script logs that it ran, and the only sign anything is wrong is a warehouse that customers or staff notice still shows the old number.
You cannot trust the bulk call's return value alone to mean every row was written. The only reliable signal is a read-back. Capture the per-row errors from the mutation, then separately query current stock for the same variants and compare the quantity you intended against the quantity Saleor actually stored. A row that reported no error but still does not match what you asked for is stale, not a fluke, and it deserves a report before anyone assumes the batch was complete.
The fix, as a flow
The script never blind-writes over a mismatch. It replays or captures the original mutation's row errors, reads back real per-warehouse stock for the affected variants, and diffs intended quantity against actual quantity for every row. Anything explained by a returned error is labeled, anything that matches is fine, and anything left over is a stale row that goes into a reconciliation report. Only when a human explicitly turns dry run off does it re-issue a small, scoped stockBulkUpdate for just the mismatched rows that a genuine failed-row error explains, then re-reads and re-diffs to confirm convergence.
Build it step by step
Get an app token with read and write access to stock
Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read and write product variants and stock, 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 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 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;
}
Read back actual stock for the affected variants
After the bulk call runs, query current per-warehouse stock for every variant it touched. This is the only source of truth for what Saleor actually stored, independent of whatever the mutation claimed.
VARIANT_STOCKS_QUERY = """
query($ids: [ID!]!) {
productVariants(first: 100, filter: { ids: $ids }) {
edges {
node {
id
sku
stocks { quantity quantityAllocated warehouse { id name } }
}
}
}
}"""
def actual_stock_rows(variant_ids):
data = gql(VARIANT_STOCKS_QUERY, {"ids": variant_ids})["productVariants"]
rows = []
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"],
"quantity": stock["quantity"],
})
return rows
const VARIANT_STOCKS_QUERY = `
query($ids: [ID!]!) {
productVariants(first: 100, filter: { ids: $ids }) {
edges {
node {
id
sku
stocks { quantity quantityAllocated warehouse { id name } }
}
}
}
}`;
async function actualStockRows(variantIds) {
const data = (await gql(VARIANT_STOCKS_QUERY, { ids: variantIds })).productVariants;
const rows = [];
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,
quantity: stock.quantity,
});
}
}
return rows;
}
Capture the mutation's own row errors
Re-run, or keep a record of, the same stockBulkUpdate call your script originally issued with errorPolicy: REJECT_FAILED_ROWS. Read results[].errors, each with a field, message, code, and index tying it back to the input row. These are the rows Saleor itself says it could not write, which is different from a row that silently did not match.
STOCK_BULK_UPDATE = """
mutation($stocks: [StockBulkUpdateInput!]!) {
stockBulkUpdate(stocks: $stocks, errorPolicy: REJECT_FAILED_ROWS) {
results {
stock { id quantity productVariant { id } warehouse { id } }
errors { field message code }
}
count
}
}"""
def run_bulk_update(rows):
stocks_input = [
{"variantId": r["variantId"], "warehouseId": r["warehouseId"], "quantity": r["quantity"]}
for r in rows
]
result = gql(STOCK_BULK_UPDATE, {"stocks": stocks_input})["stockBulkUpdate"]
mutation_errors = []
for i, row_result in enumerate(result["results"]):
for err in (row_result.get("errors") or []):
mutation_errors.append({
"variantId": rows[i]["variantId"],
"warehouseId": rows[i]["warehouseId"],
"code": err["code"],
})
return mutation_errors
const STOCK_BULK_UPDATE = `
mutation($stocks: [StockBulkUpdateInput!]!) {
stockBulkUpdate(stocks: $stocks, errorPolicy: REJECT_FAILED_ROWS) {
results {
stock { id quantity productVariant { id } warehouse { id } }
errors { field message code }
}
count
}
}`;
async function runBulkUpdate(rows) {
const stocksInput = rows.map((r) => ({
variantId: r.variantId,
warehouseId: r.warehouseId,
quantity: r.quantity,
}));
const result = (await gql(STOCK_BULK_UPDATE, { stocks: stocksInput })).stockBulkUpdate;
const mutationErrors = [];
result.results.forEach((rowResult, i) => {
for (const err of rowResult.errors || []) {
mutationErrors.push({
variantId: rows[i].variantId,
warehouseId: rows[i].warehouseId,
code: err.code,
});
}
});
return mutationErrors;
}
Decide, with one pure function
Keep the decision in its own function that takes the intended rows, the actual rows read back, and the mutation errors captured, then returns a status per row: ok, stale, or reported_error. No I/O, so it is easy to test with plain arrays and objects.
def _key(variant_id, warehouse_id):
return f"{variant_id}:{warehouse_id}"
def diff_stock_rows(intended, actual, mutation_errors):
actual_by_key = {_key(r["variantId"], r["warehouseId"]): r["quantity"] for r in actual}
error_keys = {_key(e.get("variantId"), e.get("warehouseId")) for e in mutation_errors}
diffs = []
for row in intended:
key = _key(row["variantId"], row["warehouseId"])
actual_quantity = actual_by_key.get(key)
if key in error_keys:
status = "reported_error"
elif actual_quantity is None or actual_quantity != row["quantity"]:
status = "stale"
else:
status = "ok"
diffs.append({
"variantId": row["variantId"],
"warehouseId": row["warehouseId"],
"intendedQuantity": row["quantity"],
"actualQuantity": actual_quantity,
"status": status,
})
return diffs
function key(variantId, warehouseId) {
return `${variantId}:${warehouseId}`;
}
export function diffStockRows(intended, actual, mutationErrors) {
const actualByKey = new Map(actual.map((r) => [key(r.variantId, r.warehouseId), r.quantity]));
const errorKeys = new Set(mutationErrors.map((e) => key(e.variantId, e.warehouseId)));
return intended.map((row) => {
const k = key(row.variantId, row.warehouseId);
const actualQuantity = actualByKey.has(k) ? actualByKey.get(k) : null;
let status;
if (errorKeys.has(k)) {
status = "reported_error";
} else if (actualQuantity === null || actualQuantity !== row.quantity) {
status = "stale";
} else {
status = "ok";
}
return {
variantId: row.variantId,
warehouseId: row.warehouseId,
intendedQuantity: row.quantity,
actualQuantity,
status,
};
});
}
Report, and only repair when dry run is off
Under DRY_RUN=true, the default, the script only logs the reconciliation report of every stale and reported-error row. When DRY_RUN=false, it re-issues a new, scoped stockBulkUpdate with errorPolicy: REJECT_FAILED_ROWS for only the rows whose status is stale and whose mismatch is explained by a genuine failed-row code such as NOT_FOUND or INVALID, never the whole original batch. It then re-reads and re-diffs to confirm the row actually converged before marking it resolved.
Never blind-write over a mismatch, since a stale-looking row can just mean a concurrent write changed it after your bulk call, not that the call failed. Always dry run first and log the full diff, variant, warehouse, intended, and actual quantity, before any repair. Only ever repair the exact rows the diff flagged, and always re-diff after to confirm convergence.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, reads back actual stock, captures mutation errors, diffs intended against actual with the pure function, and only repairs the explained stale rows when a human turns off dry run.
"""Find Saleor bulk stock update rows that stayed stale or partial after
productVariantStocksUpdate or stockBulkUpdate ran.
Saleor's bulk stock mutations process a list of per-warehouse rows in one
call, but error handling is row-scoped via an errorPolicy. The default
REJECT_EVERYTHING rolls back the entire batch on any single bad row
(saleor/saleor#6479). REJECT_FAILED_ROWS and IGNORE_FAILED_ROWS instead
persist only the valid rows on purpose. A script that assumes the whole
batch always lands can end up with Stock rows still on their old quantity,
and concurrent writers can make a row look stale even after a real success
(saleor/saleor#5578).
This script never blind-writes over a mismatch. Under DRY_RUN=true (the
default) it only reports the reconciliation diff. When DRY_RUN=false it
re-issues a new, scoped stockBulkUpdate for just the rows whose mismatch a
genuine failed-row error explains, never the original batch, then re-reads
and re-diffs to confirm convergence. 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("reconcile_bulk_stock")
API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
REPAIRABLE_CODES = {"NOT_FOUND", "INVALID"}
VARIANT_STOCKS_QUERY = """
query($ids: [ID!]!) {
productVariants(first: 100, filter: { ids: $ids }) {
edges {
node {
id
sku
stocks { quantity quantityAllocated warehouse { id name } }
}
}
}
}"""
STOCK_BULK_UPDATE = """
mutation($stocks: [StockBulkUpdateInput!]!) {
stockBulkUpdate(stocks: $stocks, errorPolicy: REJECT_FAILED_ROWS) {
results {
stock { id quantity productVariant { id } warehouse { id } }
errors { field message code }
}
count
}
}"""
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 _key(variant_id, warehouse_id):
return f"{variant_id}:{warehouse_id}"
def diff_stock_rows(intended, actual, mutation_errors):
actual_by_key = {_key(r["variantId"], r["warehouseId"]): r["quantity"] for r in actual}
error_keys = {_key(e.get("variantId"), e.get("warehouseId")) for e in mutation_errors}
diffs = []
for row in intended:
key = _key(row["variantId"], row["warehouseId"])
actual_quantity = actual_by_key.get(key)
if key in error_keys:
status = "reported_error"
elif actual_quantity is None or actual_quantity != row["quantity"]:
status = "stale"
else:
status = "ok"
diffs.append({
"variantId": row["variantId"],
"warehouseId": row["warehouseId"],
"intendedQuantity": row["quantity"],
"actualQuantity": actual_quantity,
"status": status,
})
return diffs
def actual_stock_rows(variant_ids):
data = gql(VARIANT_STOCKS_QUERY, {"ids": variant_ids})["productVariants"]
rows = []
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"],
"quantity": stock["quantity"],
})
return rows
def run_bulk_update(rows):
stocks_input = [
{"variantId": r["variantId"], "warehouseId": r["warehouseId"], "quantity": r["quantity"]}
for r in rows
]
result = gql(STOCK_BULK_UPDATE, {"stocks": stocks_input})["stockBulkUpdate"]
mutation_errors = []
for i, row_result in enumerate(result["results"]):
for err in (row_result.get("errors") or []):
mutation_errors.append({
"variantId": rows[i]["variantId"],
"warehouseId": rows[i]["warehouseId"],
"code": err["code"],
})
return mutation_errors
def run(intended_rows, mutation_errors=None):
variant_ids = sorted({r["variantId"] for r in intended_rows})
actual = actual_stock_rows(variant_ids)
mutation_errors = mutation_errors or []
diffs = diff_stock_rows(intended_rows, actual, mutation_errors)
stale = [d for d in diffs if d["status"] == "stale"]
for d in diffs:
if d["status"] != "ok":
log.warning(
"%s variant=%s warehouse=%s intended=%d actual=%s",
d["status"], d["variantId"], d["warehouseId"],
d["intendedQuantity"], d["actualQuantity"],
)
repairable = [
d for d in stale
if any(e["code"] in REPAIRABLE_CODES
for e in mutation_errors
if e.get("variantId") == d["variantId"] and e.get("warehouseId") == d["warehouseId"])
]
if not repairable:
log.info("Done. %d stale row(s) reported, none auto-repairable.", len(stale))
return diffs
log.info("%d stale row(s) explained by a repairable error. %s",
len(repairable), "would repair" if DRY_RUN else "repairing")
if DRY_RUN:
return diffs
repair_rows = [
{"variantId": d["variantId"], "warehouseId": d["warehouseId"], "quantity": d["intendedQuantity"]}
for d in repairable
]
run_bulk_update(repair_rows)
confirm_actual = actual_stock_rows(variant_ids)
confirm_diffs = diff_stock_rows(intended_rows, confirm_actual, [])
still_stale = [d for d in confirm_diffs if d["status"] == "stale"]
log.info("Repair done. %d row(s) still stale after re-diff.", len(still_stale))
return confirm_diffs
if __name__ == "__main__":
run([])
/**
* Find Saleor bulk stock update rows that stayed stale or partial after
* productVariantStocksUpdate or stockBulkUpdate ran.
*
* Saleor's bulk stock mutations process a list of per-warehouse rows in one
* call, but error handling is row-scoped via an errorPolicy. The default
* REJECT_EVERYTHING rolls back the entire batch on any single bad row
* (saleor/saleor#6479). REJECT_FAILED_ROWS and IGNORE_FAILED_ROWS instead
* persist only the valid rows on purpose. A script that assumes the whole
* batch always lands can end up with Stock rows still on their old
* quantity, and concurrent writers can make a row look stale even after a
* real success (saleor/saleor#5578).
*
* This script never blind-writes over a mismatch. Under DRY_RUN=true (the
* default) it only reports the reconciliation diff. When DRY_RUN=false it
* re-issues a new, scoped stockBulkUpdate for just the rows whose mismatch
* a genuine failed-row error explains, never the original batch, then
* re-reads and re-diffs to confirm convergence. Run on a schedule.
*
* Guide: https://www.allanninal.dev/saleor/bulk-stock-update-leaves-stale-rows/
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const REPAIRABLE_CODES = new Set(["NOT_FOUND", "INVALID"]);
function key(variantId, warehouseId) {
return `${variantId}:${warehouseId}`;
}
export function diffStockRows(intended, actual, mutationErrors) {
const actualByKey = new Map(actual.map((r) => [key(r.variantId, r.warehouseId), r.quantity]));
const errorKeys = new Set(mutationErrors.map((e) => key(e.variantId, e.warehouseId)));
return intended.map((row) => {
const k = key(row.variantId, row.warehouseId);
const actualQuantity = actualByKey.has(k) ? actualByKey.get(k) : null;
let status;
if (errorKeys.has(k)) {
status = "reported_error";
} else if (actualQuantity === null || actualQuantity !== row.quantity) {
status = "stale";
} else {
status = "ok";
}
return {
variantId: row.variantId,
warehouseId: row.warehouseId,
intendedQuantity: row.quantity,
actualQuantity,
status,
};
});
}
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 VARIANT_STOCKS_QUERY = `
query($ids: [ID!]!) {
productVariants(first: 100, filter: { ids: $ids }) {
edges {
node {
id
sku
stocks { quantity quantityAllocated warehouse { id name } }
}
}
}
}`;
const STOCK_BULK_UPDATE = `
mutation($stocks: [StockBulkUpdateInput!]!) {
stockBulkUpdate(stocks: $stocks, errorPolicy: REJECT_FAILED_ROWS) {
results {
stock { id quantity productVariant { id } warehouse { id } }
errors { field message code }
}
count
}
}`;
async function actualStockRows(variantIds) {
const data = (await gql(VARIANT_STOCKS_QUERY, { ids: variantIds })).productVariants;
const rows = [];
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,
quantity: stock.quantity,
});
}
}
return rows;
}
async function runBulkUpdate(rows) {
const stocksInput = rows.map((r) => ({
variantId: r.variantId,
warehouseId: r.warehouseId,
quantity: r.quantity,
}));
const result = (await gql(STOCK_BULK_UPDATE, { stocks: stocksInput })).stockBulkUpdate;
const mutationErrors = [];
result.results.forEach((rowResult, i) => {
for (const err of rowResult.errors || []) {
mutationErrors.push({
variantId: rows[i].variantId,
warehouseId: rows[i].warehouseId,
code: err.code,
});
}
});
return mutationErrors;
}
export async function run(intendedRows, mutationErrors = []) {
const variantIds = [...new Set(intendedRows.map((r) => r.variantId))].sort();
const actual = await actualStockRows(variantIds);
const diffs = diffStockRows(intendedRows, actual, mutationErrors);
const stale = diffs.filter((d) => d.status === "stale");
for (const d of diffs) {
if (d.status !== "ok") {
console.warn(
`${d.status} variant=${d.variantId} warehouse=${d.warehouseId} intended=${d.intendedQuantity} actual=${d.actualQuantity}`
);
}
}
const repairable = stale.filter((d) =>
mutationErrors.some(
(e) => e.variantId === d.variantId && e.warehouseId === d.warehouseId && REPAIRABLE_CODES.has(e.code)
)
);
if (!repairable.length) {
console.log(`Done. ${stale.length} stale row(s) reported, none auto-repairable.`);
return diffs;
}
console.log(`${repairable.length} stale row(s) explained by a repairable error. ${DRY_RUN ? "would repair" : "repairing"}`);
if (DRY_RUN) return diffs;
const repairRows = repairable.map((d) => ({
variantId: d.variantId,
warehouseId: d.warehouseId,
quantity: d.intendedQuantity,
}));
await runBulkUpdate(repairRows);
const confirmActual = await actualStockRows(variantIds);
const confirmDiffs = diffStockRows(intendedRows, confirmActual, []);
const stillStale = confirmDiffs.filter((d) => d.status === "stale");
console.log(`Repair done. ${stillStale.length} row(s) still stale after re-diff.`);
return confirmDiffs;
}
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 rows get treated as stale and which are explained away by a returned error. Because diff_stock_rows is pure, the test needs no network and no Saleor account. It just feeds in plain arrays and checks the answer.
from reconcile_bulk_stock import diff_stock_rows
V1 = "gid://saleor/ProductVariant/1"
W1 = "gid://saleor/Warehouse/1"
W2 = "gid://saleor/Warehouse/2"
def test_ok_when_actual_matches_intended():
intended = [{"variantId": V1, "warehouseId": W1, "quantity": 10}]
actual = [{"variantId": V1, "warehouseId": W1, "quantity": 10}]
result = diff_stock_rows(intended, actual, [])
assert result == [{
"variantId": V1, "warehouseId": W1,
"intendedQuantity": 10, "actualQuantity": 10, "status": "ok",
}]
def test_stale_when_actual_does_not_match():
intended = [{"variantId": V1, "warehouseId": W1, "quantity": 10}]
actual = [{"variantId": V1, "warehouseId": W1, "quantity": 4}]
result = diff_stock_rows(intended, actual, [])
assert result[0]["status"] == "stale"
assert result[0]["actualQuantity"] == 4
def test_stale_when_actual_missing_entirely():
intended = [{"variantId": V1, "warehouseId": W2, "quantity": 5}]
result = diff_stock_rows(intended, [], [])
assert result[0]["status"] == "stale"
assert result[0]["actualQuantity"] is None
def test_reported_error_takes_priority_over_mismatch():
intended = [{"variantId": V1, "warehouseId": W1, "quantity": 10}]
actual = [{"variantId": V1, "warehouseId": W1, "quantity": 4}]
errors = [{"variantId": V1, "warehouseId": W1, "code": "NOT_FOUND"}]
result = diff_stock_rows(intended, actual, errors)
assert result[0]["status"] == "reported_error"
def test_multiple_rows_get_independent_status():
intended = [
{"variantId": V1, "warehouseId": W1, "quantity": 10},
{"variantId": V1, "warehouseId": W2, "quantity": 20},
]
actual = [
{"variantId": V1, "warehouseId": W1, "quantity": 10},
{"variantId": V1, "warehouseId": W2, "quantity": 1},
]
result = diff_stock_rows(intended, actual, [])
statuses = {(r["warehouseId"]): r["status"] for r in result}
assert statuses[W1] == "ok"
assert statuses[W2] == "stale"
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffStockRows } from "./reconcile-bulk-stock.js";
const V1 = "gid://saleor/ProductVariant/1";
const W1 = "gid://saleor/Warehouse/1";
const W2 = "gid://saleor/Warehouse/2";
test("ok when actual matches intended", () => {
const intended = [{ variantId: V1, warehouseId: W1, quantity: 10 }];
const actual = [{ variantId: V1, warehouseId: W1, quantity: 10 }];
const result = diffStockRows(intended, actual, []);
assert.deepEqual(result, [{
variantId: V1, warehouseId: W1,
intendedQuantity: 10, actualQuantity: 10, status: "ok",
}]);
});
test("stale when actual does not match", () => {
const intended = [{ variantId: V1, warehouseId: W1, quantity: 10 }];
const actual = [{ variantId: V1, warehouseId: W1, quantity: 4 }];
const result = diffStockRows(intended, actual, []);
assert.equal(result[0].status, "stale");
assert.equal(result[0].actualQuantity, 4);
});
test("stale when actual missing entirely", () => {
const intended = [{ variantId: V1, warehouseId: W2, quantity: 5 }];
const result = diffStockRows(intended, [], []);
assert.equal(result[0].status, "stale");
assert.equal(result[0].actualQuantity, null);
});
test("reported error takes priority over mismatch", () => {
const intended = [{ variantId: V1, warehouseId: W1, quantity: 10 }];
const actual = [{ variantId: V1, warehouseId: W1, quantity: 4 }];
const errors = [{ variantId: V1, warehouseId: W1, code: "NOT_FOUND" }];
const result = diffStockRows(intended, actual, errors);
assert.equal(result[0].status, "reported_error");
});
test("multiple rows get independent status", () => {
const intended = [
{ variantId: V1, warehouseId: W1, quantity: 10 },
{ variantId: V1, warehouseId: W2, quantity: 20 },
];
const actual = [
{ variantId: V1, warehouseId: W1, quantity: 10 },
{ variantId: V1, warehouseId: W2, quantity: 1 },
];
const result = diffStockRows(intended, actual, []);
const statuses = Object.fromEntries(result.map((r) => [r.warehouseId, r.status]));
assert.equal(statuses[W1], "ok");
assert.equal(statuses[W2], "stale");
});
Case studies
One bad warehouse id silently rolled back forty good ones
A furniture importer synced nightly stock counts from its ERP into Saleor with one stockBulkUpdate call per SKU, covering forty warehouses at a time, using the default error policy. One night a warehouse had been archived in Saleor but not in the ERP feed, and the entire batch rolled back for that SKU, even though the other thirty-nine rows were perfectly valid.
The team never saw an exception, since the call itself completed. Only a customer complaint about a warehouse showing stale stock led them to add this reconciler. Now the nightly job diffs intended against actual after every sync, catches the rollback immediately, and reports exactly which SKU and warehouse pair needs attention instead of losing a night's sync silently.
A stale-looking row was not actually stale
A marketplace connector pushed bulk stock corrections every few minutes. Occasionally the reconciler flagged a row as stale right after a correction ran, but a second look showed the correction had actually succeeded, and an order fulfillment had decremented the same row moments later.
Because the script never auto-repairs a mismatch unless a genuine failed-row error explains it, that row was reported, not blindly overwritten, and the team correctly left it alone rather than fighting the fulfillment's legitimate decrement with a stale intended quantity.
After this runs after every bulk stock call, a rolled-back batch or a silently skipped row gets caught within minutes instead of surfacing as a customer complaint or a bad inventory report. The team gets the exact variant, warehouse, intended, and actual quantity, and any repair stays scoped to just the rows a genuine error explains, confirmed by a re-diff, never a blind rewrite over a row that might have changed for a legitimate reason.
FAQ
Why does my Saleor bulk stock update not update every warehouse row?
Saleor's bulk stock mutations process every row you send in one call, but they hand errors off to an errorPolicy. The default REJECT_EVERYTHING rolls back the whole batch if a single row is bad, such as a warehouse not assigned to the variant's shipping zones, an unknown id, or a duplicate warehouse. REJECT_FAILED_ROWS and IGNORE_FAILED_ROWS instead persist only the valid rows and skip the bad ones. Either way, a script that assumes the whole call always succeeds can end up with some Stock rows still on their pre-update quantity.
How do I detect a stale row after a Saleor bulk stock update?
Re-run the same productVariantStocksUpdate or stockBulkUpdate call your script issued and capture its errors, bulkStockErrors, or results with per-row codes. Then query current per-warehouse stock for the same variants and compare each intended quantity to the quantity Saleor actually stored. Any row where the mutation reported no error but the read-back quantity does not match what you intended is a stale or partial row.
Is it safe to automatically rewrite stale stock rows?
Only for rows whose mismatch is explained by a genuine failed-row error such as NOT_FOUND or INVALID, and only when a human has explicitly turned off dry run. Re-issue a new, scoped stockBulkUpdate with errorPolicy REJECT_FAILED_ROWS for just those rows, never the original batch, then re-read and re-diff to confirm the row actually converged before marking it resolved.
Related field notes
Citations
On the problem:
- productVariantStocksUpdate mutation does not fully update stock. github.com/saleor/saleor/issues/6479
- Bug: Stock update mutations don't trigger webhooks. github.com/saleor/saleor/issues/11630
- Stock quantity is 0 despite that warehouse quantity has some items. github.com/saleor/saleor/issues/5578
On the solution:
- Saleor Commerce Documentation: productVariantStocksUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-stocks-update
- Saleor Commerce Documentation: stockBulkUpdate Mutation. docs.saleor.io/docs/3.x/api-reference/products/mutations/stock-bulk-update
- Saleor Commerce Documentation: Error Policy. docs.saleor.io/developer/bulks/error-policy
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 a stale stock row for you?
If this saved you from a rolled-back sync or a stale warehouse count, 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