Reconciler Inventory

Overselling from concurrent writes

Two inventory updates fired within the same second, for the same variant, at the same location. Both read the available count as five. Both decided it was fine to write a new number. One write landed, then the other landed right on top of it and erased the first one. The count that Shopify shows now has no memory of that first sale, so the store sells more than it has. Here is why the race happens and a small script that closes it with compareQuantity.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A store shelf full of items
Photo by Oxana Melis on Unsplash
The short answer

Overselling from a race happens when two writes to the same inventory item read the same starting quantity and both write a new absolute value, so the second write clobbers the first instead of combining with it. Fix it by passing compareQuantity on the inventorySetQuantities mutation with the quantity you just read. Shopify rejects the write if the live quantity has already moved, so a stale write can never silently overwrite a fresher one. Full code, tests, and a dry run guard are below.

The problem in plain words

Inventory in Shopify lives as a quantity per inventory item per location. When something changes it, like a sale, a manual count, or an app syncing stock from a warehouse, that something reads the current number, works out the new number, and writes it back.

The trouble starts when two of those somethings run close together. Say the available count is five. A checkout reserves one and plans to write four. At nearly the same instant, a warehouse sync tool reads the count, also sees five, and plans to write five again after confirming a shelf count. Whichever write lands last wins, and it wins completely, with no idea the other write ever happened. If the sync's five lands after the checkout's four, the four is gone, and the store believes it still has five to sell when only four are real. Multiply that by a busy sale weekend running several inventory apps at once, and the drift adds up fast into real oversold orders.

Checkout A reads available: 5 Warehouse sync B reads available: 5 Writes available: 4 after reserving 1 Writes available: 5 shelf count confirmed B lands last, overwrites A Available shows 5 but only 4 truly exist
Both processes read the same starting quantity. The write that lands last replaces the count completely, so the earlier change disappears and the store oversells by the amount it lost.

Why it happens

Nothing here is a Shopify bug. It is what happens whenever two writers share one number and neither one knows about the other. A few common ways stores end up with this race:

This is a common source of confusion because each write looks correct in isolation. The checkout math is right. The warehouse sync math is right. The only thing missing is a way to notice that the ground shifted between the read and the write. See the citations at the end for the exact docs on how Shopify's inventory mutations work.

The key insight

The fix is not to write less often or to lock the whole store. It is to make every write prove it still knows the truth. Shopify's inventorySetQuantities mutation accepts a compareQuantity field alongside the new quantity. When you pass the value you just read, Shopify checks that the live value has not moved before it applies your write. If it has moved, the mutation fails cleanly instead of silently clobbering someone else's change, and your script can re-read and retry with fresh numbers.

The fix, as a flow

We do not touch checkout code and we do not add a lock. We add a job that reads the current available quantity for each tracked inventory item right before it writes, sends that value as compareQuantity along with the corrected quantity, and treats a rejected write as a sign to re-read and try again rather than an error to ignore. Anything Shopify accepts is logged as a real correction. Anything Shopify rejects means someone else moved the number first, so we back off and reconcile fresh.

Reconcile job runs on a timer Read available now per item, per location Compute correct qty against known truth Still matches compareQuantity? yes no, re-read and retry Quantity applied count now correct
The write only applies when the live quantity still matches what the job read. A stale write is rejected and the job re-reads fresh numbers instead of guessing.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_inventory and write_inventory scopes, plus read_locations, and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOCATION_ID="gid://shopify/Location/1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOCATION_ID="gid://shopify/Location/1"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read live quantities and to run the mutation.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": 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 SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List tracked inventory levels for the location

Ask for inventory items at the location you are reconciling, reading back the inventory item id, the SKU, and the current available quantity from quantities(names: ["available"]). We page through with a cursor so the job covers a large catalog without missing anything.

step3.py
LEVELS_QUERY = """
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    inventoryLevels(first: 50, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        item { id sku }
        quantities(names: ["available"]) { name quantity }
      }
    }
  }
}"""

def inventory_levels(location_id):
    cursor = None
    while True:
        loc = gql(LEVELS_QUERY, {"cursor": cursor, "locationId": location_id})["location"]
        data = loc["inventoryLevels"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const LEVELS_QUERY = `
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    inventoryLevels(first: 50, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        item { id sku }
        quantities(names: ["available"]) { name quantity }
      }
    }
  }
}`;

async function* inventoryLevels(locationId) {
  let cursor = null;
  while (true) {
    const loc = (await gql(LEVELS_QUERY, { cursor, locationId })).location;
    const data = loc.inventoryLevels;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the quantity we just read, the quantity we believe is correct, and returns the exact write we should send, or nothing if there is nothing to fix. A pure function like this is easy to read and easy to test, which we do later. It always carries compareQuantity set to the quantity we just read, never to a number read minutes ago, because that is what lets Shopify catch a race.

decide.py
def plan_write(item_id, location_id, current_available, correct_available):
    """Return the inventorySetQuantities write to send, or None if nothing to fix.
    current_available must be read immediately before this call, never cached,
    so compareQuantity always reflects the true live value at write time.
    """
    if current_available == correct_available:
        return None
    return {
        "name": "available",
        "reason": "correction",
        "ignoreCompareQuantityFailures": False,
        "quantities": [{
            "inventoryItemId": item_id,
            "locationId": location_id,
            "quantity": correct_available,
            "compareQuantity": current_available,
        }],
    }
decide.js
export function planWrite(itemId, locationId, currentAvailable, correctAvailable) {
  // currentAvailable must be read immediately before this call, never cached,
  // so compareQuantity always reflects the true live value at write time.
  if (currentAvailable === correctAvailable) return null;
  return {
    name: "available",
    reason: "correction",
    ignoreCompareQuantityFailures: false,
    quantities: [{
      inventoryItemId: itemId,
      locationId,
      quantity: correctAvailable,
      compareQuantity: currentAvailable,
    }],
  };
}
5

Write with compareQuantity, and treat a rejection as a signal

Call inventorySetQuantities with the plan from step four. When the live quantity has already moved since we read it, Shopify returns a userErrors entry rather than applying the write. That is success for the safety mechanism, even though it is not the write we wanted, so the script logs it and moves on to re-read that item on the next pass instead of forcing the write through.

apply.py
SET_QUANTITIES_MUTATION = """
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { createdAt }
    userErrors { field message code }
  }
}"""

def apply_write(write):
    result = gql(SET_QUANTITIES_MUTATION, {"input": write})["inventorySetQuantities"]
    errors = result["userErrors"]
    if errors:
        stale = any(e.get("code") == "COMPARE_QUANTITY_STALE" for e in errors)
        if stale:
            return "stale"
        raise RuntimeError(errors)
    return "applied"
apply.js
const SET_QUANTITIES_MUTATION = `
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { createdAt }
    userErrors { field message code }
  }
}`;

async function applyWrite(write) {
  const result = (await gql(SET_QUANTITIES_MUTATION, { input: write })).inventorySetQuantities;
  const errors = result.userErrors;
  if (errors.length) {
    const stale = errors.some((e) => e.code === "COMPARE_QUANTITY_STALE");
    if (stale) return "stale";
    throw new Error(JSON.stringify(errors));
  }
  return "applied";
}
6

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which items it would correct and by how much. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how fast your inventory moves, for example every few minutes during a sale.

Run it safe

Always start with DRY_RUN=true, and always read current_available in the same pass you write it, never from an earlier snapshot. A stale compareQuantity defeats the whole point, since Shopify would be comparing against a number that is already wrong.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because a rejected write from a stale compareQuantity never corrupts anything, it simply waits for the next pass to re-read and try again.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

reconcile_inventory.py
"""Reconcile Shopify inventory without racing other writers.
Reads the live available quantity right before writing and passes it as
compareQuantity, so a write is rejected instead of silently overwriting
a change made by another process in between. 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("reconcile_inventory")

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
LOCATION_ID = os.environ["LOCATION_ID"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

LEVELS_QUERY = """
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    inventoryLevels(first: 50, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        item { id sku }
        quantities(names: ["available"]) { name quantity }
      }
    }
  }
}"""

SET_QUANTITIES_MUTATION = """
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { createdAt }
    userErrors { field message code }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": 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 available_quantity(node):
    for q in node.get("quantities") or []:
        if q.get("name") == "available":
            return q.get("quantity")
    return None


def plan_write(item_id, location_id, current_available, correct_available):
    """Return the inventorySetQuantities write to send, or None if nothing to fix.
    current_available must be read immediately before this call, never cached,
    so compareQuantity always reflects the true live value at write time.
    """
    if current_available == correct_available:
        return None
    return {
        "name": "available",
        "reason": "correction",
        "ignoreCompareQuantityFailures": False,
        "quantities": [{
            "inventoryItemId": item_id,
            "locationId": location_id,
            "quantity": correct_available,
            "compareQuantity": current_available,
        }],
    }


def inventory_levels(location_id):
    cursor = None
    while True:
        loc = gql(LEVELS_QUERY, {"cursor": cursor, "locationId": location_id})["location"]
        data = loc["inventoryLevels"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def apply_write(write):
    result = gql(SET_QUANTITIES_MUTATION, {"input": write})["inventorySetQuantities"]
    errors = result["userErrors"]
    if errors:
        stale = any(e.get("code") == "COMPARE_QUANTITY_STALE" for e in errors)
        if stale:
            return "stale"
        raise RuntimeError(errors)
    return "applied"


def correct_quantity_for(sku):
    """Look up the truth for this SKU, for example a warehouse feed or a fresh count.
    Replace this with your own source of correct stock.
    """
    raise NotImplementedError


def run():
    corrected = 0
    stale = 0
    for node in inventory_levels(LOCATION_ID):
        item_id = node["item"]["id"]
        sku = node["item"]["sku"]
        current = available_quantity(node)
        if current is None:
            continue
        correct = correct_quantity_for(sku)
        write = plan_write(item_id, LOCATION_ID, current, correct)
        if write is None:
            continue
        log.info("SKU %s: %s -> %s. %s", sku, current, correct,
                  "would write" if DRY_RUN else "writing")
        if not DRY_RUN:
            outcome = apply_write(write)
            if outcome == "stale":
                stale += 1
                log.warning("SKU %s: compareQuantity stale, another write landed first. Skipping this pass.", sku)
                continue
        corrected += 1
    log.info("Done. %d item(s) %s, %d rejected as stale.",
              corrected, "to correct" if DRY_RUN else "corrected", stale)


if __name__ == "__main__":
    run()
reconcile-inventory.js
/**
 * Reconcile Shopify inventory without racing other writers.
 * Reads the live available quantity right before writing and passes it as
 * compareQuantity, so a write is rejected instead of silently overwriting
 * a change made by another process in between. Run on a schedule.
 * Safe to run again and again.
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const LOCATION_ID = process.env.LOCATION_ID || "gid://shopify/Location/1";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function availableQuantity(node) {
  for (const q of node.quantities || []) {
    if (q.name === "available") return q.quantity;
  }
  return null;
}

export function planWrite(itemId, locationId, currentAvailable, correctAvailable) {
  // currentAvailable must be read immediately before this call, never cached,
  // so compareQuantity always reflects the true live value at write time.
  if (currentAvailable === correctAvailable) return null;
  return {
    name: "available",
    reason: "correction",
    ignoreCompareQuantityFailures: false,
    quantities: [{
      inventoryItemId: itemId,
      locationId,
      quantity: correctAvailable,
      compareQuantity: currentAvailable,
    }],
  };
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const LEVELS_QUERY = `
query($cursor: String, $locationId: ID!) {
  location(id: $locationId) {
    inventoryLevels(first: 50, after: $cursor) {
      pageInfo { hasNextPage endCursor }
      nodes {
        item { id sku }
        quantities(names: ["available"]) { name quantity }
      }
    }
  }
}`;

const SET_QUANTITIES_MUTATION = `
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { createdAt }
    userErrors { field message code }
  }
}`;

async function* inventoryLevels(locationId) {
  let cursor = null;
  while (true) {
    const loc = (await gql(LEVELS_QUERY, { cursor, locationId })).location;
    const data = loc.inventoryLevels;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function applyWrite(write) {
  const result = (await gql(SET_QUANTITIES_MUTATION, { input: write })).inventorySetQuantities;
  const errors = result.userErrors;
  if (errors.length) {
    const stale = errors.some((e) => e.code === "COMPARE_QUANTITY_STALE");
    if (stale) return "stale";
    throw new Error(JSON.stringify(errors));
  }
  return "applied";
}

async function correctQuantityFor(sku) {
  // Look up the truth for this SKU, for example a warehouse feed or a fresh count.
  // Replace this with your own source of correct stock.
  throw new Error("not implemented");
}

export async function run() {
  let corrected = 0;
  let stale = 0;
  for await (const node of inventoryLevels(LOCATION_ID)) {
    const itemId = node.item.id;
    const sku = node.item.sku;
    const current = availableQuantity(node);
    if (current === null) continue;
    const correct = await correctQuantityFor(sku);
    const write = planWrite(itemId, LOCATION_ID, current, correct);
    if (!write) continue;
    console.log(`SKU ${sku}: ${current} -> ${correct}. ${DRY_RUN ? "would write" : "writing"}`);
    if (!DRY_RUN) {
      const outcome = await applyWrite(write);
      if (outcome === "stale") {
        stale++;
        console.warn(`SKU ${sku}: compareQuantity stale, another write landed first. Skipping this pass.`);
        continue;
      }
    }
    corrected++;
  }
  console.log(`Done. ${corrected} item(s) ${DRY_RUN ? "to correct" : "corrected"}, ${stale} rejected as stale.`);
}

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 what gets written and what protects that write from a race. Because we kept plan_write and available_quantity pure, the tests need no network and no Shopify account. They just feed in plain objects and check the answer.

test_overselling_compare_quantity.py
from reconcile_inventory import plan_write, available_quantity


def test_no_write_when_already_correct():
    assert plan_write("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 5) is None


def test_write_carries_compare_quantity_as_current_value():
    write = plan_write("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 4)
    q = write["quantities"][0]
    assert q["compareQuantity"] == 5
    assert q["quantity"] == 4


def test_write_never_forces_through_compare_failures():
    write = plan_write("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 4)
    assert write["ignoreCompareQuantityFailures"] is False


def test_available_quantity_reads_named_entry():
    node = {"quantities": [{"name": "committed", "quantity": 2}, {"name": "available", "quantity": 7}]}
    assert available_quantity(node) == 7


def test_available_quantity_missing_returns_none():
    assert available_quantity({"quantities": [{"name": "committed", "quantity": 2}]}) is None
reconcile-inventory.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { planWrite, availableQuantity } from "./reconcile-inventory.js";

test("no write when already correct", () => {
  assert.equal(planWrite("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 5), null);
});

test("write carries compareQuantity as the current value", () => {
  const write = planWrite("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 4);
  const q = write.quantities[0];
  assert.equal(q.compareQuantity, 5);
  assert.equal(q.quantity, 4);
});

test("write never forces through compare failures", () => {
  const write = planWrite("gid://shopify/InventoryItem/1", "gid://shopify/Location/1", 5, 4);
  assert.equal(write.ignoreCompareQuantityFailures, false);
});

test("availableQuantity reads the named entry", () => {
  const node = { quantities: [{ name: "committed", quantity: 2 }, { name: "available", quantity: 7 }] };
  assert.equal(availableQuantity(node), 7);
});

test("availableQuantity missing returns null", () => {
  assert.equal(availableQuantity({ quantities: [{ name: "committed", quantity: 2 }] }), null);
});

Case studies

Flash sale

The sneaker drop that sold twelve pairs of a stock of ten

A streetwear store ran a timed drop with a countdown page. Checkout wrote inventory on every sale, and a separate hype-tracking app polled and rewrote the same quantity every few seconds to keep its own dashboard fresh. During the first minute, several of those writes landed within the same second and clobbered each other, so the storefront still showed stock after the shelf was empty.

After adding compareQuantity to every write, the dashboard app's stale writes started failing instead of erasing real sales. The team saw the rejection count spike during the drop, which told them exactly how close the race had been, and stock finally ran out at the right number.

Multi channel

The store selling the same stock online and in a physical shop

A boutique used Shopify POS in the shop and the online store for the same location's inventory. A busy Saturday meant a cashier and an online buyer sometimes bought the last unit of something within seconds of each other, and whichever write landed second wiped out the first, so the count stayed at one when it should have been zero.

The reconciliation job now runs every five minutes, reads the live count right before writing, and lets Shopify reject any write where the count already moved. Those rejections just mean the job re-reads next pass, and the shop stopped promising a unit that was already sold at the till.

What good looks like

After this runs on a schedule, no writer can silently erase another writer's change. Every write proves it still knows the truth before Shopify accepts it, a stale write fails loudly instead of corrupting the count quietly, and the available quantity finally matches what is really on the shelf. Keep the read-then-write step tight, since any gap between reading compareQuantity and sending it reopens the race.

FAQ

Why did my Shopify store oversell an item that showed stock?

Two processes read the same available quantity at close to the same moment, both decided a sale or an adjustment was safe, and both wrote back a new number. The second write overwrote the first one instead of building on it, so one of the changes was silently lost and the count drifted above what was really on the shelf.

What does compareQuantity do on inventorySetQuantities?

compareQuantity tells Shopify what quantity you believe is currently set. Shopify only applies your new quantity when the current value still matches what you expected. If another process already changed it, the mutation fails instead of silently overwriting that other change, which is what stops the race.

Is it safe to run an inventory reconciliation script automatically?

Yes, when it only reports mismatches by default with a dry run flag, always reads the current quantity right before writing, and passes that quantity as compareQuantity so a write is rejected rather than applied on stale data. That combination means the script can never make an oversell worse.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: managing inventory quantities and locations. help.shopify.com/en/manual/products/inventory
  2. Shopify Help Center: overselling and how oversold items are handled. help.shopify.com/en/manual/products/inventory/managing-inventory/oversell
  3. Shopify Community: inventory counts drifting when multiple apps write at once. community.shopify.com shopify apis and sdks

On the solution:

  1. Shopify Admin GraphQL: the inventorySetQuantities mutation, including compareQuantity. shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities
  2. Shopify Admin GraphQL: the InventoryLevel object and its quantities field. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryLevel
  3. Shopify Admin GraphQL: managing inventory quantities concurrently across locations. shopify.dev/docs/apps/build/orders-fulfillment/inventory-management-apps/manage-quantities-states

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, 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 stop your overselling?

If this saved you a pile of angry customers or a wrong stock 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 Shopify field notes