Repair Inventory

Shopify negative inventory from overselling

A popular item is set to keep selling when it runs out, a rush of orders comes in, and now its stock reads minus fourteen. Nothing on a shelf can be negative, but Shopify shows it because you sold past zero. That negative number then poisons your reports, your reorder points, and any tool that reads stock. Here is why it happens and a small job that finds the oversold variants and resets them to zero in a safe way.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A large warehouse filled with lots of shelves
Photo by Lance Chang on Unsplash
The short answer

When a variant is set to continue selling when out of stock, Shopify keeps taking orders past zero and the available count goes negative. Run a small Python or Node.js job that lists variants with a negative available quantity, finds the locations below zero, and sets each one back to zero with inventorySetQuantities, passing compareQuantity so a concurrent change is rejected instead of clobbered. Full code, tests, and a dry run guard are below.

The problem in plain words

Every variant has an inventory policy. If it is set to continue selling when out of stock, Shopify does not stop at zero. It keeps accepting orders and keeps subtracting, so the available number drops below zero. A count of minus fourteen means you took fourteen more orders than you had stock for.

That is useful as a record of how far you oversold, but it is a problem everywhere else. Reports treat it as real stock, reorder tools do their math on a negative base, and connected systems can behave in strange ways when they read a number that should never be below zero.

Keep selling when out of stock Orders past zero a rush comes in Available: -14 below zero Reports and reorders wrong
Selling past zero pushes the available count negative. Real stock can never be below zero, so every tool that reads it now reads a wrong number.

Why it happens

Negative inventory is almost always the continue-selling setting doing exactly what it was told, at a bad moment. A few common triggers:

The setting itself can be the right call for some products. The problem is only that the negative number lingers after the rush, and nothing resets it. See the citations at the end for the docs on inventory states and setting quantities.

The key insight

A negative available count is a leftover, not a live fact. Once the rush is over, the honest value is zero until you actually restock. Resetting the oversold locations to zero clears the bad number, and using compareQuantity makes that reset safe even while new orders keep arriving.

The fix, as a flow

We do not change the inventory policy or cancel any orders. We add a job that finds the variants whose available quantity is below zero, looks at each location, and sets the negative ones back to zero. The write carries the expected current value, so if stock moved since we read it, Shopify rejects the change and we simply run again.

Scheduled job runs on a timer List negative variants inventory_quantity below 0 Check each location available value Below zero? yes no, skip Set to zero with compareQuantity
The job resets only the locations that are below zero, and only if the value has not changed since the read. Everything else is left alone.

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_products and write_inventory scopes 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 DRY_RUN="true"   # start safe, change to false to correct
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true"   // start safe, change to false to correct
2

List the oversold variants

Ask for variants whose inventory quantity is below zero, and for each read the inventory item and its levels per location, including the available quantity. The search filter does the first pass for you, so the job only pulls the variants that are actually oversold. We page through with a cursor.

step2.py
VARIANTS_QUERY = """
query($cursor: String) {
  productVariants(first: 50, after: $cursor, query: "inventory_quantity:<0") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id sku
      inventoryItem {
        id
        inventoryLevels(first: 20) {
          nodes {
            location { id name }
            quantities(names: ["available"]) { name quantity }
          }
        }
      }
    }
  }
}"""

def oversold_variants():
    cursor = None
    while True:
        data = gql(VARIANTS_QUERY, {"cursor": cursor})["productVariants"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step2.js
const VARIANTS_QUERY = `
query($cursor: String) {
  productVariants(first: 50, after: $cursor, query: "inventory_quantity:<0") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id sku
      inventoryItem {
        id
        inventoryLevels(first: 20) {
          nodes {
            location { id name }
            quantities(names: ["available"]) { name quantity }
          }
        }
      }
    }
  }
}`;

async function* oversoldVariants() {
  let cursor = null;
  while (true) {
    const data = (await gql(VARIANTS_QUERY, { cursor })).productVariants;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
3

Find the negative locations, with one pure function

A variant can be stocked in several locations, and only some may be negative. Keep the logic that picks out the below-zero locations in its own function that takes a variant and returns the levels to correct, each with the inventory item id, the location id, and the current negative value. A pure function like this is easy to read and easy to test.

decide.py
def available_of(level):
    for q in level.get("quantities") or []:
        if q.get("name") == "available":
            return q.get("quantity")
    return None

def oversold_levels(variant):
    out = []
    item = variant.get("inventoryItem") or {}
    for lvl in ((item.get("inventoryLevels") or {}).get("nodes") or []):
        avail = available_of(lvl)
        if avail is not None and avail < 0:
            out.append({
                "inventoryItemId": item.get("id"),
                "locationId": (lvl.get("location") or {}).get("id"),
                "available": avail,
            })
    return out
decide.js
export function availableOf(level) {
  for (const q of level.quantities || []) {
    if (q.name === "available") return q.quantity;
  }
  return null;
}

export function oversoldLevels(variant) {
  const out = [];
  const item = variant.inventoryItem || {};
  for (const lvl of item.inventoryLevels?.nodes || []) {
    const avail = availableOf(lvl);
    if (avail !== null && avail < 0) {
      out.push({ inventoryItemId: item.id, locationId: lvl.location?.id, available: avail });
    }
  }
  return out;
}
4

Reset the quantity to zero, safely

For each oversold location, call inventorySetQuantities to set available to zero. Pass compareQuantity with the current negative value and leave ignoreCompareQuantity off. If new orders changed the count since you read it, Shopify rejects the write, so you never overwrite a fresher number. Always read back userErrors.

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

def correct(inventory_item_id, location_id, current):
    result = gql(SET_MUTATION, {"input": {
        "name": "available",
        "reason": "correction",
        "ignoreCompareQuantity": False,
        "quantities": [{
            "inventoryItemId": inventory_item_id,
            "locationId": location_id,
            "quantity": 0,
            "compareQuantity": current,
        }],
    }})["inventorySetQuantities"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
apply.js
const SET_MUTATION = `
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { id }
    userErrors { field message }
  }
}`;

async function correct(inventoryItemId, locationId, current) {
  const result = (await gql(SET_MUTATION, {
    input: {
      name: "available",
      reason: "correction",
      ignoreCompareQuantity: false,
      quantities: [{ inventoryItemId, locationId, quantity: 0, compareQuantity: current }],
    },
  })).inventorySetQuantities;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
5

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the job only reports which variants and locations it would reset. Read the output, agree with it, then switch it off. Running it a few times a day keeps negative numbers from lingering after a busy period.

Run it safe

Start with DRY_RUN=true. This job resets oversold counts to zero, which is right after the orders are handled, but only run it for real once you have fulfilled or accounted for the oversold orders. The compareQuantity guard means a live change is never overwritten.

The full code

Here is the complete job 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 the compare guard rejects any write against a value that has moved.

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

fix_negative_inventory.py
"""Find and correct Shopify variants that oversold into negative inventory.
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("fix_negative_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"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

VARIANTS_QUERY = """
query($cursor: String) {
  productVariants(first: 50, after: $cursor, query: "inventory_quantity:<0") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id sku
      inventoryItem {
        id
        inventoryLevels(first: 20) {
          nodes {
            location { id name }
            quantities(names: ["available"]) { name quantity }
          }
        }
      }
    }
  }
}"""

SET_MUTATION = """
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { id }
    userErrors { field message }
  }
}"""


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_of(level):
    for q in level.get("quantities") or []:
        if q.get("name") == "available":
            return q.get("quantity")
    return None


def oversold_levels(variant):
    out = []
    item = variant.get("inventoryItem") or {}
    for lvl in ((item.get("inventoryLevels") or {}).get("nodes") or []):
        avail = available_of(lvl)
        if avail is not None and avail < 0:
            out.append({
                "inventoryItemId": item.get("id"),
                "locationId": (lvl.get("location") or {}).get("id"),
                "available": avail,
            })
    return out


def correct(inventory_item_id, location_id, current):
    result = gql(SET_MUTATION, {"input": {
        "name": "available",
        "reason": "correction",
        "ignoreCompareQuantity": False,
        "quantities": [{
            "inventoryItemId": inventory_item_id,
            "locationId": location_id,
            "quantity": 0,
            "compareQuantity": current,
        }],
    }})["inventorySetQuantities"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def oversold_variants():
    cursor = None
    while True:
        data = gql(VARIANTS_QUERY, {"cursor": cursor})["productVariants"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    fixed = 0
    for variant in oversold_variants():
        for lvl in oversold_levels(variant):
            log.warning("Variant %s at %s is %s. %s", variant.get("sku") or variant["id"],
                        lvl["locationId"], lvl["available"], "would set to 0" if DRY_RUN else "setting to 0")
            if not DRY_RUN:
                correct(lvl["inventoryItemId"], lvl["locationId"], lvl["available"])
            fixed += 1
    log.info("Done. %d oversold level(s) %s.", fixed, "to correct" if DRY_RUN else "corrected")


if __name__ == "__main__":
    run()
fix-negative-inventory.js
/**
 * Find and correct Shopify variants that oversold into negative inventory.
 * 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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

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

export function oversoldLevels(variant) {
  const out = [];
  const item = variant.inventoryItem || {};
  for (const lvl of item.inventoryLevels?.nodes || []) {
    const avail = availableOf(lvl);
    if (avail !== null && avail < 0) {
      out.push({ inventoryItemId: item.id, locationId: lvl.location?.id, available: avail });
    }
  }
  return out;
}

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 VARIANTS_QUERY = `
query($cursor: String) {
  productVariants(first: 50, after: $cursor, query: "inventory_quantity:<0") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id sku
      inventoryItem {
        id
        inventoryLevels(first: 20) {
          nodes {
            location { id name }
            quantities(names: ["available"]) { name quantity }
          }
        }
      }
    }
  }
}`;

const SET_MUTATION = `
mutation($input: InventorySetQuantitiesInput!) {
  inventorySetQuantities(input: $input) {
    inventoryAdjustmentGroup { id }
    userErrors { field message }
  }
}`;

async function* oversoldVariants() {
  let cursor = null;
  while (true) {
    const data = (await gql(VARIANTS_QUERY, { cursor })).productVariants;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function correct(inventoryItemId, locationId, current) {
  const result = (await gql(SET_MUTATION, {
    input: {
      name: "available",
      reason: "correction",
      ignoreCompareQuantity: false,
      quantities: [{ inventoryItemId, locationId, quantity: 0, compareQuantity: current }],
    },
  })).inventorySetQuantities;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  let fixed = 0;
  for await (const variant of oversoldVariants()) {
    for (const lvl of oversoldLevels(variant)) {
      console.warn(`Variant ${variant.sku || variant.id} at ${lvl.locationId} is ${lvl.available}. ${DRY_RUN ? "would set to 0" : "setting to 0"}`);
      if (!DRY_RUN) await correct(lvl.inventoryItemId, lvl.locationId, lvl.available);
      fixed++;
    }
  }
  console.log(`Done. ${fixed} oversold level(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The part most worth testing is the function that picks the negative locations, because it decides what gets reset. Because it is pure, the test needs no network and no Shopify account. It just feeds in plain inventory levels and checks which come back.

test_oversold_levels.py
from fix_negative_inventory import oversold_levels, available_of


def level(location_id, available):
    return {
        "location": {"id": location_id, "name": location_id},
        "quantities": [{"name": "available", "quantity": available}],
    }


def variant(*levels):
    return {"id": "gid://shopify/ProductVariant/1", "sku": "SKU1",
            "inventoryItem": {"id": "gid://shopify/InventoryItem/9",
                              "inventoryLevels": {"nodes": list(levels)}}}


def test_available_of_reads_the_named_quantity():
    assert available_of(level("loc/1", -3)) == -3


def test_no_oversold_when_all_non_negative():
    assert oversold_levels(variant(level("loc/1", 0), level("loc/2", 5))) == []


def test_finds_single_oversold_location():
    out = oversold_levels(variant(level("loc/1", -2), level("loc/2", 4)))
    assert len(out) == 1 and out[0]["available"] == -2


def test_finds_multiple_oversold_locations():
    out = oversold_levels(variant(level("loc/1", -2), level("loc/2", -7)))
    assert sorted(l["available"] for l in out) == [-7, -2]
oversold.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { oversoldLevels, availableOf } from "./fix-negative-inventory.js";

const level = (locationId, available) => ({
  location: { id: locationId, name: locationId },
  quantities: [{ name: "available", quantity: available }],
});

const variant = (...levels) => ({
  id: "gid://shopify/ProductVariant/1",
  sku: "SKU1",
  inventoryItem: { id: "gid://shopify/InventoryItem/9", inventoryLevels: { nodes: levels } },
});

test("availableOf reads the named quantity", () => {
  assert.equal(availableOf(level("loc/1", -3)), -3);
});

test("no oversold when all non-negative", () => {
  assert.deepEqual(oversoldLevels(variant(level("loc/1", 0), level("loc/2", 5))), []);
});

test("finds single oversold location", () => {
  const out = oversoldLevels(variant(level("loc/1", -2), level("loc/2", 4)));
  assert.equal(out.length, 1);
  assert.equal(out[0].available, -2);
});

test("finds multiple oversold locations", () => {
  const out = oversoldLevels(variant(level("loc/1", -2), level("loc/2", -7)));
  assert.deepEqual(out.map((l) => l.available).sort((a, b) => a - b), [-7, -2]);
});

Case studies

Flash sale

The drop that went to minus forty

A streetwear brand ran a limited drop with continue-selling left on. Orders came faster than the count could keep up, and a few variants landed deep in the negatives. The next morning their reorder tool tried to buy stock based on those negative numbers.

The job on an hourly schedule reset the oversold variants to zero once the drop closed. The reorder math went back to sane, and the team decided per variant which ones to restock.

Double sync

The two apps that both subtracted

A store ran two apps that each reduced stock on a sale, so counts fell twice as fast and several bestsellers slipped below zero even though there was stock on the shelf. The negative numbers made the store look out of stock when it was not.

The team ran the job in dry run, saw the exact list of oversold variants and locations, fixed the double-subtract, then let the job reset the counts to zero so a proper recount could start from a clean base.

What good looks like

After this runs on a schedule, a negative count is a brief blip during a rush, not a number that sticks around for weeks. Reports and reorder tools read honest values, and the compare guard means the reset never fights the live sales still coming in. You decide what to restock from a clean zero, not a confusing minus.

FAQ

Why does my Shopify inventory show a negative number?

When a variant is set to continue selling when out of stock, Shopify keeps accepting orders past zero, so the available count goes negative. It is a record of how far you oversold. The stock itself cannot really be negative, so the number should be corrected back to zero once you have caught up.

How do I fix negative inventory in Shopify with the API?

List the variants whose available quantity is below zero, then set each oversold location back to zero with the inventorySetQuantities mutation. Pass compareQuantity with the current negative value so that if stock changed since you read it, Shopify rejects the write instead of overwriting a newer number.

What is compareQuantity and why use it?

compareQuantity tells Shopify the value you expect the quantity to be right now. If the real quantity has changed since you read it, the write is rejected instead of clobbering the newer value. It makes the correction safe to run even while orders keep coming in.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: inventory tracking and continue selling when out of stock. help.shopify.com/en/manual/products/inventory/managing-inventory-quantities
  2. Shopify Help Center: the available, committed, and on-hand inventory states. help.shopify.com inventory states
  3. Shopify Community: inventory showing negative numbers after a sale. community.shopify.com

On the solution:

  1. Shopify Admin GraphQL: the inventorySetQuantities mutation and compareQuantity. shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities
  2. Shopify Admin GraphQL: the InventoryLevel object and quantities by name. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryLevel
  3. Shopify Admin GraphQL: the productVariants query and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/productVariants

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 clean up your stock counts?

If this cleared a pile of negative numbers for you, 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