Skip to content

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.

Python and Node.js Saleor GraphQL API Report first, repair only when confirmed
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

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.

One bulk call rows for many warehouses One row is bad unknown id, bad zone, or duplicate warehouse errorPolicy decides what survives Rollback or skip not always obvious to the caller Stock row stays stale
The bulk call does not crash, so the script moves on assuming every row landed. Depending on errorPolicy, one bad row can roll back the whole batch or just be skipped, and either way a Stock row can be left on its old quantity.

Why it happens

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.

The key insight

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.

Capture intended rows and mutation errors Query actual stock per variant, per warehouse Diff intended vs actual quantity Stale and explained? yes, dry run off no, report only Scoped repair, re-diff to confirm never the whole original batch
The script always reports every stale row first. It only re-writes rows whose mismatch a genuine failed-row error explains, and only when DRY_RUN is off, scoping the repair call to just those rows.

Build it step by step

1

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.

setup (shell)
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
setup (shell)
// 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
2

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.

step2.py
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"]
step2.js
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;
}
3

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.

step3.py
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
step3.js
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;
}
4

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.

step4.py
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
step4.js
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;
}
5

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.

decide.py
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
decide.js
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,
    };
  });
}
6

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.

Run it safe

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
reconcile_bulk_stock.py
"""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([])
reconcile-bulk-stock.js
/**
 * 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.

test_bulk_stock_diff.py
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"
diff.test.js
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

Multi-warehouse sync

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.

Concurrent writers

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.

What good looks like

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:

  1. productVariantStocksUpdate mutation does not fully update stock. github.com/saleor/saleor/issues/6479
  2. Bug: Stock update mutations don't trigger webhooks. github.com/saleor/saleor/issues/11630
  3. Stock quantity is 0 despite that warehouse quantity has some items. github.com/saleor/saleor/issues/5578

On the solution:

  1. Saleor Commerce Documentation: productVariantStocksUpdate Mutation. docs.saleor.io/api-reference/products/mutations/product-variant-stocks-update
  2. Saleor Commerce Documentation: stockBulkUpdate Mutation. docs.saleor.io/docs/3.x/api-reference/products/mutations/stock-bulk-update
  3. 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.

Contact me on LinkedIn

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

Back to all Saleor field notes