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.
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.
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:
- A product is set to keep selling when out of stock, on purpose for pre-orders or made-to-order, and a spike pushes it well past zero.
- A viral moment or a flash sale brings a burst of orders faster than stock can keep up.
- Two systems both subtract from the same stock, so the count falls faster than real sales.
- A sync from another tool wrote a stale low number and orders pushed it under zero from there.
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.
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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
}
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.
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
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;
}
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.
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"])
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));
}
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.
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.
"""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()
/**
* 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.
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]
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
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.
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.
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:
- Shopify Help Center: inventory tracking and continue selling when out of stock. help.shopify.com/en/manual/products/inventory/managing-inventory-quantities
- Shopify Help Center: the available, committed, and on-hand inventory states. help.shopify.com inventory states
- Shopify Community: inventory showing negative numbers after a sale. community.shopify.com
On the solution:
- Shopify Admin GraphQL: the
inventorySetQuantitiesmutation and compareQuantity. shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities - Shopify Admin GraphQL: the InventoryLevel object and quantities by name. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryLevel
- Shopify Admin GraphQL: the
productVariantsquery 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.
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