Skip to content

Reconciler Stock & Inventory

Stock quantity reads zero despite items in warehouse

A pallet just came off the truck. Staff can see the boxes. But the storefront says the variant is out of stock, and Saleor agrees, because the number it is reading says zero. Nobody moved the units out, nobody sold them all. The stored quantity and the physical shelf simply drifted apart, and Saleor has no built in alarm for that. Here is why the two numbers split and a script that finds every place they disagree so a human can confirm the real count before anything gets overwritten.

Python and Node.js Saleor GraphQL API Report only (no auto writes)
A worker with a tablet in a warehouse
Photo by Rodrigo Rodrigues on Unsplash
The short answer

Stock.quantity is a plain integer column that store staff or import jobs write directly, through the dashboard, a CSV import, or the stockBulkUpdate and productVariantStocksUpdate mutations. quantityAllocated is derived separately from live Allocation rows tied to order lines. If a variant is created without a Stock row properly attached to the warehouse (see saleor/saleor#5578), or a bulk import writes quantity=0 as a placeholder before a follow up update lands, the two numbers drift apart, and quantityAvailable, which is quantity minus quantityAllocated clamped at zero, reports out of stock even though units are physically present. Run a small Python or Node.js script that pages through variants and stock, compares quantity against summed Allocation rows and any known physical count, and reports every pair where the numbers do not add up. It never overwrites inventory on its own. Full code, tests, and a dry run guarded repair are below.

The problem in plain words

Saleor never counts your warehouse itself. It keeps one number, Stock.quantity, per variant and warehouse pair, and that number is only ever as good as whoever last typed it in, be that a staff member in the dashboard, a CSV import job, or an API call from your warehouse management system. Nothing in Saleor cross checks that number against a real shelf count.

Separately, Saleor keeps track of how much of that quantity is already promised to open orders, through Allocation rows, and rolls that up into quantityAllocated. The number the storefront actually shows, quantityAvailable, is just quantity minus quantityAllocated, clamped so it never goes negative. That formula is fine as long as quantity reflects reality. It breaks the moment a variant gets created without its Stock row wired up to a warehouse, which is a documented gap when a product is created without proper variant and attribute handling, or the moment a bulk import writes a placeholder quantity=0 and the real count never follows because the second update job failed or was never run. The warehouse has stock. Saleor's stored number does not, and it has no way to know it is wrong.

Real shelf count units physically present no Stock row, or quantity=0 Stock.quantity 0 not attached, or placeholder quantityAvailable quantity minus quantityAllocated Out of stock storefront
The units are on the shelf, but the Stock row backing that variant and warehouse never learned it, so the storefront's math clamps to zero.

Why it happens

None of these throw an error. The storefront just quietly says sold out, staff restock the shelf and nothing changes, and the only way to notice is to compare what Saleor's numbers say against what a person can see with their own eyes, or against an order backlog that implies stock should exist.

The key insight

You cannot fix this by guessing a new number. Overwriting quantity without a confirmed physical count just replaces one wrong number with another. The right tool is a detector: page through stock, compare quantity against the sum of live Allocation rows and, when you have it, a known physical count from a warehouse system export, and flag every pair where those do not line up. The correction only ever runs once a human hands the script a number they stand behind.

The fix, as a flow

The script runs after the fact, on a schedule. It enumerates warehouses, pages through variants and their stock rows per warehouse, and for each pair sums the live allocations and checks against any known physical count you can supply from a WMS export. Where the numbers disagree, it reports the variant, the warehouse, the current quantity, the allocation sum, and the suspected physical count as a flagged pair. Only when DRY_RUN is off and a human has supplied a confirmed count does it call stockBulkUpdate, one pair at a time, then re-query quantityAvailable to confirm the drift is gone before moving to the next one.

Scheduled job runs on a timer Page warehouses + variant stocks Sum allocations, read known physical count quantity is inconsistent? yes no, consistent Report drift, then apply if confirmed stockBulkUpdate, one pair at a time
The script always reports the drift first. It only calls stockBulkUpdate when DRY_RUN is off and a human supplied a confirmed physical count, one variant and warehouse pair at a time.

Build it step by step

1

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, plus write access to products if you plan to run the repair. 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.

setup (shell)
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
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 SALEOR_CHANNEL="default-channel"
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

Enumerate warehouses, then page through variant stock

First page through warehouses to know every warehouse a variant should carry stock in. Then ask for productVariant on the channel you care about, reading back quantityAvailable and each stocks row's quantity and quantityAllocated per warehouse. Page with a cursor so the job handles a full catalog.

step3.py
WAREHOUSES_QUERY = """
query($cursor: String) {
  warehouses(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges { node { id name slug } }
  }
}"""

VARIANTS_QUERY = """
query($channel: String!, $cursor: String) {
  productVariants(channel: $channel, first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        quantityAvailable(countryCode: US)
        stocks { warehouse { id slug } quantity quantityAllocated }
      }
    }
  }
}"""

def all_warehouses():
    cursor = None
    rows = []
    while True:
        data = gql(WAREHOUSES_QUERY, {"cursor": cursor})["warehouses"]
        rows.extend(edge["node"] for edge in data["edges"])
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]

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"],
                    "quantity": stock["quantity"],
                    "quantityAllocated": stock["quantityAllocated"],
                })
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]
step3.js
const WAREHOUSES_QUERY = `
query($cursor: String) {
  warehouses(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges { node { id name slug } }
  }
}`;

const VARIANTS_QUERY = `
query($channel: String!, $cursor: String) {
  productVariants(channel: $channel, first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        quantityAvailable(countryCode: US)
        stocks { warehouse { id slug } quantity quantityAllocated }
      }
    }
  }
}`;

async function allWarehouses() {
  let cursor = null;
  const rows = [];
  while (true) {
    const data = (await gql(WAREHOUSES_QUERY, { cursor })).warehouses;
    rows.push(...data.edges.map((edge) => edge.node));
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}

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,
          quantity: stock.quantity,
          quantityAllocated: stock.quantityAllocated,
        });
      }
    }
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes one stock row, the allocations tied to it, and an optional known physical count, then returns whether it drifted and why. A pure function like this is easy to read and test, which we do later. It flags a row when the allocation sum exceeds the stored quantity, when a supplied physical count is higher than the stored quantity, or when quantity is zero while open allocations exist, three separate and equally suspicious situations.

decide.py
def detect_stock_drift(stock, allocations, known_physical_count=None):
    allocated_sum = sum(a["quantity"] for a in allocations)
    quantity = stock["quantity"]

    if quantity == 0 and allocated_sum > 0:
        return {"isDrift": True, "delta": allocated_sum, "reason": "zero_quantity_with_open_allocations"}

    if allocated_sum > quantity:
        return {"isDrift": True, "delta": allocated_sum - quantity, "reason": "allocated_exceeds_quantity"}

    if known_physical_count is not None and known_physical_count > quantity:
        return {"isDrift": True, "delta": known_physical_count - quantity, "reason": "quantity_below_known_physical_count"}

    return {"isDrift": False, "delta": 0, "reason": ""}
decide.js
export function detectStockDrift(stock, allocations, knownPhysicalCount = null) {
  const allocatedSum = allocations.reduce((sum, a) => sum + a.quantity, 0);
  const quantity = stock.quantity;

  if (quantity === 0 && allocatedSum > 0) {
    return { isDrift: true, delta: allocatedSum, reason: "zero_quantity_with_open_allocations" };
  }

  if (allocatedSum > quantity) {
    return { isDrift: true, delta: allocatedSum - quantity, reason: "allocated_exceeds_quantity" };
  }

  if (knownPhysicalCount !== null && knownPhysicalCount > quantity) {
    return { isDrift: true, delta: knownPhysicalCount - quantity, reason: "quantity_below_known_physical_count" };
  }

  return { isDrift: false, delta: 0, reason: "" };
}
5

Cross-check with real allocations from orders

Do not trust quantityAllocated alone, it is Saleor's own cache. For staff level depth, pull allocations per order line directly, filtering to orders in UNFULFILLED or PARTIALLY_FULFILLED status, and match them to the variant and warehouse pair you are checking. This needs MANAGE_PRODUCTS or MANAGE_ORDERS permission on the app token.

crosscheck.py
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 allocations_for(variant_id, warehouse_id):
    cursor = None
    matches = []
    while True:
        data = gql(ORDERS_WITH_ALLOCATIONS_QUERY, {"cursor": cursor})["orders"]
        for edge in data["edges"]:
            for line in edge["node"]["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({"quantity": allocation["quantity"]})
        if not data["pageInfo"]["hasNextPage"]:
            return matches
        cursor = data["pageInfo"]["endCursor"]
crosscheck.js
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 allocationsFor(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) {
      for (const line of edge.node.lines) {
        if (!line.variant || line.variant.id !== variantId) continue;
        for (const allocation of line.allocations) {
          if (allocation.warehouse.id === warehouseId) matches.push({ quantity: allocation.quantity });
        }
      }
    }
    if (!data.pageInfo.hasNextPage) return matches;
    cursor = data.pageInfo.endCursor;
  }
}
6

Report the drift, and only repair when a human confirms it

Under DRY_RUN=true, the default, the script only emits a report per (variantId, warehouseId, currentQuantity, quantityAllocated, suspectedPhysicalCount, deltaSource). When DRY_RUN=false and you supply an authoritative external count from your warehouse system, it calls stockBulkUpdate for one variant and warehouse pair at a time, then re-queries quantityAvailable to confirm the drift resolved before moving to the next flagged pair.

Run it safe

Never blind-write a corrected quantity. This script only proposes a repair when you hand it a physical count you already trust, and it always applies it one pair at a time, confirming quantityAvailable afterward before touching the next one. Physical count drift must be confirmed by a human or a WMS export, not guessed at.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through warehouses, variants, and stock, cross-checks against live allocations, flags every drifted variant and warehouse pair, and only writes a correction when a confirmed physical count is supplied and DRY_RUN is off.

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.
detect_stock_drift.py
"""Find Saleor variant and warehouse pairs where Stock.quantity does not
match reality: either live Allocation rows exceed it, a known physical
count from a WMS export is higher than it, or it is zero while open
allocations exist (saleor/saleor#5578, #4058, #543).

This script never overwrites inventory on its own. Under DRY_RUN=true
(the default) it only reports drifted pairs. When DRY_RUN=false and a
confirmed physical count is supplied per variant and warehouse, it
applies the correction one pair at a time and re-checks quantityAvailable
before moving on. 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("detect_stock_drift")

API_URL = os.environ.get("SALEOR_API_URL", "https://store.saleor.cloud/graphql/")
TOKEN = os.environ.get("SALEOR_AUTH_TOKEN", "dummy-token")
CHANNEL = os.environ.get("SALEOR_CHANNEL", "default-channel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

WAREHOUSES_QUERY = """
query($cursor: String) {
  warehouses(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges { node { id name slug } }
  }
}"""

VARIANTS_QUERY = """
query($channel: String!, $cursor: String) {
  productVariants(channel: $channel, first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        quantityAvailable(countryCode: US)
        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 } }
        }
      }
    }
  }
}"""

STOCK_BULK_UPDATE = """
mutation($variantId: ID!, $warehouseId: ID!, $quantity: Int!) {
  stockBulkUpdate(stocks: [{ variantId: $variantId, warehouseId: $warehouseId, quantity: $quantity }]) {
    results { stock { id quantity } errors { field message code } }
  }
}"""

VARIANT_AVAILABILITY_QUERY = """
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) { id quantityAvailable(countryCode: US) }
}"""


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 detect_stock_drift(stock, allocations, known_physical_count=None):
    allocated_sum = sum(a["quantity"] for a in allocations)
    quantity = stock["quantity"]

    if quantity == 0 and allocated_sum > 0:
        return {"isDrift": True, "delta": allocated_sum, "reason": "zero_quantity_with_open_allocations"}

    if allocated_sum > quantity:
        return {"isDrift": True, "delta": allocated_sum - quantity, "reason": "allocated_exceeds_quantity"}

    if known_physical_count is not None and known_physical_count > quantity:
        return {"isDrift": True, "delta": known_physical_count - quantity, "reason": "quantity_below_known_physical_count"}

    return {"isDrift": False, "delta": 0, "reason": ""}


def all_warehouses():
    cursor = None
    rows = []
    while True:
        data = gql(WAREHOUSES_QUERY, {"cursor": cursor})["warehouses"]
        rows.extend(edge["node"] for edge in data["edges"])
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]


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"],
                    "quantity": stock["quantity"],
                    "quantityAllocated": stock["quantityAllocated"],
                })
        if not data["pageInfo"]["hasNextPage"]:
            return rows
        cursor = data["pageInfo"]["endCursor"]


def allocations_for(variant_id, warehouse_id):
    cursor = None
    matches = []
    while True:
        data = gql(ORDERS_WITH_ALLOCATIONS_QUERY, {"cursor": cursor})["orders"]
        for edge in data["edges"]:
            for line in edge["node"]["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({"quantity": allocation["quantity"]})
        if not data["pageInfo"]["hasNextPage"]:
            return matches
        cursor = data["pageInfo"]["endCursor"]


def apply_correction(variant_id, warehouse_id, corrected_quantity):
    result = gql(STOCK_BULK_UPDATE, {
        "variantId": variant_id,
        "warehouseId": warehouse_id,
        "quantity": corrected_quantity,
    })["stockBulkUpdate"]
    for item in result["results"]:
        if item["errors"]:
            raise RuntimeError(item["errors"])
    return result


def confirm_available(variant_id, channel):
    data = gql(VARIANT_AVAILABILITY_QUERY, {"id": variant_id, "channel": channel})["productVariant"]
    return data["quantityAvailable"]


def run(known_physical_counts=None):
    known_physical_counts = known_physical_counts or {}
    stocks = stock_snapshot(CHANNEL)
    flagged = []

    for stock in stocks:
        allocations = allocations_for(stock["variantId"], stock["warehouseId"])
        known = known_physical_counts.get((stock["variantId"], stock["warehouseId"]))
        result = detect_stock_drift(stock, allocations, known)
        if not result["isDrift"]:
            continue
        flagged.append({**stock, **result, "suspectedPhysicalCount": known})
        log.warning(
            "DRIFT sku=%s variant=%s warehouse=%s quantity=%d allocated=%d reason=%s delta=%d",
            stock["sku"], stock["variantId"], stock["warehouseId"],
            stock["quantity"], stock["quantityAllocated"], result["reason"], result["delta"],
        )

    if DRY_RUN:
        log.info("Done (dry run). %d drifted variant and warehouse pair(s) reported.", len(flagged))
        return flagged

    for row in flagged:
        known = row["suspectedPhysicalCount"]
        if known is None:
            log.info("Skipping %s at %s, no confirmed physical count supplied.", row["sku"], row["warehouseId"])
            continue
        apply_correction(row["variantId"], row["warehouseId"], known)
        available = confirm_available(row["variantId"], CHANNEL)
        log.info("Corrected %s at %s to %d. quantityAvailable now %s.", row["sku"], row["warehouseId"], known, available)

    log.info("Done. %d drifted variant and warehouse pair(s) processed.", len(flagged))
    return flagged


if __name__ == "__main__":
    run()
detect-stock-drift.js
/**
 * Find Saleor variant and warehouse pairs where Stock.quantity does not
 * match reality: either live Allocation rows exceed it, a known physical
 * count from a WMS export is higher than it, or it is zero while open
 * allocations exist (saleor/saleor#5578, #4058, #543).
 *
 * This script never overwrites inventory on its own. Under DRY_RUN=true
 * (the default) it only reports drifted pairs. When DRY_RUN=false and a
 * confirmed physical count is supplied per variant and warehouse, it
 * applies the correction one pair at a time and re-checks quantityAvailable
 * before moving on. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/stock-quantity-zero-despite-inventory/
 */
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 detectStockDrift(stock, allocations, knownPhysicalCount = null) {
  const allocatedSum = allocations.reduce((sum, a) => sum + a.quantity, 0);
  const quantity = stock.quantity;

  if (quantity === 0 && allocatedSum > 0) {
    return { isDrift: true, delta: allocatedSum, reason: "zero_quantity_with_open_allocations" };
  }

  if (allocatedSum > quantity) {
    return { isDrift: true, delta: allocatedSum - quantity, reason: "allocated_exceeds_quantity" };
  }

  if (knownPhysicalCount !== null && knownPhysicalCount > quantity) {
    return { isDrift: true, delta: knownPhysicalCount - quantity, reason: "quantity_below_known_physical_count" };
  }

  return { isDrift: false, delta: 0, reason: "" };
}

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 WAREHOUSES_QUERY = `
query($cursor: String) {
  warehouses(first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges { node { id name slug } }
  }
}`;

const VARIANTS_QUERY = `
query($channel: String!, $cursor: String) {
  productVariants(channel: $channel, first: 50, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    edges {
      node {
        id
        sku
        quantityAvailable(countryCode: US)
        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 } }
        }
      }
    }
  }
}`;

const STOCK_BULK_UPDATE = `
mutation($variantId: ID!, $warehouseId: ID!, $quantity: Int!) {
  stockBulkUpdate(stocks: [{ variantId: $variantId, warehouseId: $warehouseId, quantity: $quantity }]) {
    results { stock { id quantity } errors { field message code } }
  }
}`;

const VARIANT_AVAILABILITY_QUERY = `
query($id: ID!, $channel: String!) {
  productVariant(id: $id, channel: $channel) { id quantityAvailable(countryCode: US) }
}`;

async function allWarehouses() {
  let cursor = null;
  const rows = [];
  while (true) {
    const data = (await gql(WAREHOUSES_QUERY, { cursor })).warehouses;
    rows.push(...data.edges.map((edge) => edge.node));
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}

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,
          quantity: stock.quantity,
          quantityAllocated: stock.quantityAllocated,
        });
      }
    }
    if (!data.pageInfo.hasNextPage) return rows;
    cursor = data.pageInfo.endCursor;
  }
}

async function allocationsFor(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) {
      for (const line of edge.node.lines) {
        if (!line.variant || line.variant.id !== variantId) continue;
        for (const allocation of line.allocations) {
          if (allocation.warehouse.id === warehouseId) matches.push({ quantity: allocation.quantity });
        }
      }
    }
    if (!data.pageInfo.hasNextPage) return matches;
    cursor = data.pageInfo.endCursor;
  }
}

async function applyCorrection(variantId, warehouseId, correctedQuantity) {
  const result = (await gql(STOCK_BULK_UPDATE, {
    variantId, warehouseId, quantity: correctedQuantity,
  })).stockBulkUpdate;
  for (const item of result.results) {
    if (item.errors.length) throw new Error(JSON.stringify(item.errors));
  }
  return result;
}

async function confirmAvailable(variantId, channel) {
  const data = (await gql(VARIANT_AVAILABILITY_QUERY, { id: variantId, channel })).productVariant;
  return data.quantityAvailable;
}

export async function run(knownPhysicalCounts = {}) {
  const stocks = await stockSnapshot(CHANNEL);
  const flagged = [];

  for (const stock of stocks) {
    const allocations = await allocationsFor(stock.variantId, stock.warehouseId);
    const known = knownPhysicalCounts[`${stock.variantId}::${stock.warehouseId}`] ?? null;
    const result = detectStockDrift(stock, allocations, known);
    if (!result.isDrift) continue;
    flagged.push({ ...stock, ...result, suspectedPhysicalCount: known });
    console.warn(
      `DRIFT sku=${stock.sku} variant=${stock.variantId} warehouse=${stock.warehouseId} quantity=${stock.quantity} allocated=${stock.quantityAllocated} reason=${result.reason} delta=${result.delta}`
    );
  }

  if (DRY_RUN) {
    console.log(`Done (dry run). ${flagged.length} drifted variant and warehouse pair(s) reported.`);
    return flagged;
  }

  for (const row of flagged) {
    if (row.suspectedPhysicalCount === null) {
      console.log(`Skipping ${row.sku} at ${row.warehouseId}, no confirmed physical count supplied.`);
      continue;
    }
    await applyCorrection(row.variantId, row.warehouseId, row.suspectedPhysicalCount);
    const available = await confirmAvailable(row.variantId, CHANNEL);
    console.log(`Corrected ${row.sku} at ${row.warehouseId} to ${row.suspectedPhysicalCount}. quantityAvailable now ${available}.`);
  }

  console.log(`Done. ${flagged.length} drifted variant and warehouse pair(s) processed.`);
  return flagged;
}

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 pairs get flagged and, eventually, corrected. Because detect_stock_drift is pure, the test needs no network and no Saleor account. It just feeds in plain stock rows and allocation lists and checks the answer.

test_stock_drift.py
from detect_stock_drift import detect_stock_drift


def stock(**over):
    base = {
        "variantId": "gid://saleor/ProductVariant/1",
        "sku": "SKU-1",
        "warehouseId": "gid://saleor/Warehouse/1",
        "quantity": 5,
        "quantityAllocated": 0,
    }
    base.update(over)
    return base


def test_no_drift_when_quantity_covers_allocations():
    result = detect_stock_drift(stock(), [{"quantity": 2}])
    assert result["isDrift"] is False


def test_drift_when_allocated_exceeds_quantity():
    result = detect_stock_drift(stock(quantity=1), [{"quantity": 3}])
    assert result == {"isDrift": True, "delta": 2, "reason": "allocated_exceeds_quantity"}


def test_drift_when_known_physical_count_exceeds_quantity():
    result = detect_stock_drift(stock(quantity=0), [], known_physical_count=12)
    assert result == {"isDrift": True, "delta": 12, "reason": "quantity_below_known_physical_count"}


def test_drift_when_zero_quantity_with_open_allocations():
    result = detect_stock_drift(stock(quantity=0), [{"quantity": 1}])
    assert result == {"isDrift": True, "delta": 1, "reason": "zero_quantity_with_open_allocations"}


def test_no_drift_when_zero_quantity_and_no_allocations():
    result = detect_stock_drift(stock(quantity=0), [])
    assert result["isDrift"] is False


def test_no_drift_when_known_physical_count_matches():
    result = detect_stock_drift(stock(quantity=5), [], known_physical_count=5)
    assert result["isDrift"] is False
drift.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { detectStockDrift } from "./detect-stock-drift.js";

const stock = (over = {}) => ({
  variantId: "gid://saleor/ProductVariant/1",
  sku: "SKU-1",
  warehouseId: "gid://saleor/Warehouse/1",
  quantity: 5,
  quantityAllocated: 0,
  ...over,
});

test("no drift when quantity covers allocations", () => {
  const result = detectStockDrift(stock(), [{ quantity: 2 }]);
  assert.equal(result.isDrift, false);
});

test("drift when allocated exceeds quantity", () => {
  const result = detectStockDrift(stock({ quantity: 1 }), [{ quantity: 3 }]);
  assert.deepEqual(result, { isDrift: true, delta: 2, reason: "allocated_exceeds_quantity" });
});

test("drift when known physical count exceeds quantity", () => {
  const result = detectStockDrift(stock({ quantity: 0 }), [], 12);
  assert.deepEqual(result, { isDrift: true, delta: 12, reason: "quantity_below_known_physical_count" });
});

test("drift when zero quantity with open allocations", () => {
  const result = detectStockDrift(stock({ quantity: 0 }), [{ quantity: 1 }]);
  assert.deepEqual(result, { isDrift: true, delta: 1, reason: "zero_quantity_with_open_allocations" });
});

test("no drift when zero quantity and no allocations", () => {
  const result = detectStockDrift(stock({ quantity: 0 }), []);
  assert.equal(result.isDrift, false);
});

test("no drift when known physical count matches", () => {
  const result = detectStockDrift(stock({ quantity: 5 }), [], 5);
  assert.equal(result.isDrift, false);
});

Case studies

Product import

A migrated catalog left half its variants at zero

A furniture store migrated three thousand SKUs from a legacy platform through a CSV import that created products and variants in one pass and was meant to backfill real stock counts in a second pass. The second pass job crashed partway through and nobody reran it, so about four hundred variants sat at quantity=0 while boxes of the actual product sat in the warehouse.

Running the detector against a WMS export of physical counts flagged every one of those variants under quantity_below_known_physical_count in one pass, instead of support tickets trickling in over weeks as customers hit sold out pages for things that were fully in stock.

Variant creation gap

A new color variant never got a Stock row

A skincare brand added a new shade as a product variant through the dashboard, restocked it physically the same week, but the variant had been created through a flow that skipped attaching a Stock row to the main warehouse, the exact gap tracked in saleor/saleor#5578. The storefront showed it sold out from day one.

The nightly detector run caught it as zero_quantity_with_open_allocations once a few backorder-style holds landed on it, well before merchandising noticed the new shade had zero sales despite being on shelves in two stores.

What good looks like

After this runs on a schedule, a Stock row that drifted from reality gets caught within a day instead of being discovered when a customer complains or a staff member notices the shelf does not match the screen. The team gets the exact variant, warehouse, current quantity, allocation sum, and suggested physical count to work from, and the actual correction stays a human decision backed by a real count, not a script guessing at a number.

FAQ

Why does Saleor show a variant as out of stock when the warehouse has units?

Stock.quantity is a plain number that staff, an import job, or a bulk mutation writes directly, it is not calculated from a physical count. If the variant was created without a Stock row properly attached to the warehouse, or a bulk import wrote quantity=0 as a placeholder before a follow up update landed, the stored quantity never matches the real shelf count, so quantityAvailable is clamped to zero even though units physically exist.

What is the difference between quantity and quantityAllocated on a Stock row?

quantity is the on hand count someone wrote for that variant and warehouse. quantityAllocated is derived separately from live Allocation rows tied to open order lines. quantityAvailable is quantity minus quantityAllocated, clamped at zero, so a Stock row with quantity=0 always reports unavailable no matter how many Allocation rows exist or do not exist.

Is it safe to script a fix for a stock quantity that reads zero?

Detecting the drift is safe and worth automating, since it only reads productVariant, stocks, and allocations. Writing a new quantity is not something a script should decide on its own. The safe pattern is to report every variant and warehouse pair where the numbers do not add up, and only apply stockBulkUpdate once a human supplies a confirmed physical count from a warehouse system.

Related field notes

Citations

On the problem:

  1. Stock quantity is 0 despite that warehouse quantity has some items. github.com/saleor/saleor/issues/5578
  2. Discrepancy is Allocated Inventory Under Variant. github.com/saleor/saleor/issues/4058
  3. Concurrent checkouts allocate more stocks to customers than the quantity available. github.com/saleor/saleor/issues/543

On the solution:

  1. Saleor Commerce Documentation: Stock Object. docs.saleor.io/docs/3.x/api-reference/products/objects/stock
  2. Saleor Commerce Documentation: Stock Allocation. docs.saleor.io/developer/stock/stock-allocation
  3. Saleor Commerce Documentation: Bulk Stock Update. docs.saleor.io/developer/bulks/bulk-stock

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 stock drift for you?

If this saved you from a sold out page that should not have existed, or gave your ops 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

Back to all Saleor field notes