Diagnostic Inventory
On-hand vs available vs committed
Someone read the physical stock count, called it the sellable count, and shipped a number to the storefront. The two are not the same thing. Shopify keeps on_hand, committed, and available as three separate quantities on purpose, and the moment code or a manual edit treats the raw stock as if it were free to sell, the store either oversells what is already promised to someone else, or hides stock that a customer could have bought. Here is what the real quantity names mean and a small script that reports the difference wherever it shows up.
on_hand is the physical count at a location. committed is how much of that count is already promised to open orders. available is what is left for a new customer to buy, and it is always on_hand - committed - damaged - safety_stock, never the raw on_hand number by itself. Run a small Python or Node.js script that reads every inventory level's quantities field, recomputes what available should be, and tags any item where the two disagree so a human can look at it. Full code, tests, and a dry run guard are below.
The problem in plain words
Shopify does not store one inventory number. It stores several, and each one answers a different question. on_hand answers "how many are physically at this location." committed answers "how many of those are already spoken for by orders that exist but have not shipped." available answers the only question a storefront actually needs answered: "how many can I sell right now."
The bug shows up when a report, a sync job, or a custom app reads on_hand and treats it as available. A warehouse feed says forty units are on the shelf, so the storefront is told forty are buyable, but six of those forty are already sitting inside orders waiting to be packed. The tenth customer that day buys a unit that does not exist for them, and the store has to cancel or delay the order. The opposite mistake happens too: available gets set once and never recalculated as committed changes, so stock looks sold out while units are quietly free on the shelf.
Why it happens
Shopify's own admin does the subtraction correctly, so the confusion almost always comes from code outside the admin that reads the wrong field or forgets one of the buckets. A few common ways stores end up here:
- A custom sync or ERP integration pulls the physical count and calls
inventorySetQuantitieswith the nameavailablewhen it meant to updateon_hand, so committed stock gets sold twice. - A report or dashboard queries
on_handbecause it is the simplest number, then presents it to a merchant or a customer as "in stock," skipping committed entirely. - An app reserves stock by writing to
committeddirectly but the reservation is never released when the order cancels, so available stays understated forever. - Damaged stock or safety stock buffers are set once and never subtracted consistently, so two different parts of the system disagree on what available should be.
This is a common source of confusion because Shopify calls all of these "inventory," and older docs and third-party inventory apps sometimes use the plain word "stock" for any of the three. Reading the exact field names on the InventoryLevel object clears it up fast. See the citations at the end for the exact field references.
available is not a number Shopify stores in isolation, it is the result of an identity that has to hold: on_hand - committed - damaged - safety_stock = available. So the fix is not "which number is correct," it is "does the identity still hold." We read all the buckets with the quantities field, recompute the right side, and compare it to what Shopify actually reports for available. Any gap means something wrote to one bucket without keeping the others in sync, and that is worth a human look before it becomes a wrong order.
The fix, as a flow
We do not change any stock numbers. We add a read-only job that walks every tracked variant, reads its inventory levels at each location, recomputes what available should be from the other buckets, and tags the inventory item for review only when the recomputed number disagrees with what Shopify reports. Everything that ties out is left alone.
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, read_inventory, and write_products scopes (the write scope covers the tag mutation) 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 REVIEW_TAG="inventory-drift"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="inventory-drift"
export DRY_RUN="true" // start safe, change to false to write
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 inventory levels and to run the tag mutation.
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"]
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;
}
List every tracked variant's inventory levels
Ask for each variant's inventory item, then its inventory levels at every location, and read the quantities field with the exact names we need: available, on_hand, committed, damaged, and safety_stock. We page through with a cursor so the job handles a full catalog.
ITEMS_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id sku
inventoryItem {
id tracked
inventoryLevels(first: 20) {
nodes {
location { id name }
quantities(names: ["available", "on_hand", "committed", "damaged", "safety_stock"]) {
name quantity
}
}
}
}
}
}
}"""
def tracked_variants():
cursor = None
while True:
data = gql(ITEMS_QUERY, {"cursor": cursor})["productVariants"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ITEMS_QUERY = `
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id sku
inventoryItem {
id tracked
inventoryLevels(first: 20) {
nodes {
location { id name }
quantities(names: ["available", "on_hand", "committed", "damaged", "safety_stock"]) {
name quantity
}
}
}
}
}
}
}`;
async function* trackedVariants() {
let cursor = null;
while (true) {
const data = (await gql(ITEMS_QUERY, { cursor })).productVariants;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with pure functions
Turn the quantities list into a plain lookup, then compute the drift as the available Shopify reports minus what the other buckets imply it should be. Zero means the level ties out. A pure function like this needs no network to test, which we do later. Inventory counts from Shopify are already whole numbers, so this stays in plain integers with no rounding to worry about.
QUANTITY_NAMES = ["available", "on_hand", "committed", "damaged", "safety_stock"]
def quantities_by_name(level):
out = {name: 0 for name in QUANTITY_NAMES}
for q in level.get("quantities") or []:
if q.get("name") in out:
out[q["name"]] = q.get("quantity", 0)
return out
def drift_for_level(level):
q = quantities_by_name(level)
expected_available = q["on_hand"] - q["committed"] - q["damaged"] - q["safety_stock"]
return q["available"] - expected_available
def levels_with_drift(inventory_item):
out = []
for lvl in (inventory_item.get("inventoryLevels") or {}).get("nodes") or []:
drift = drift_for_level(lvl)
if drift != 0:
out.append({
"locationId": (lvl.get("location") or {}).get("id"),
"locationName": (lvl.get("location") or {}).get("name"),
"drift": drift,
})
return out
const QUANTITY_NAMES = ["available", "on_hand", "committed", "damaged", "safety_stock"];
export function quantitiesByName(level) {
const out = Object.fromEntries(QUANTITY_NAMES.map((n) => [n, 0]));
for (const q of level.quantities || []) {
if (q.name in out) out[q.name] = q.quantity ?? 0;
}
return out;
}
export function driftForLevel(level) {
const q = quantitiesByName(level);
const expectedAvailable = q.on_hand - q.committed - q.damaged - q.safety_stock;
return q.available - expectedAvailable;
}
export function levelsWithDrift(inventoryItem) {
const out = [];
for (const lvl of inventoryItem.inventoryLevels?.nodes || []) {
const drift = driftForLevel(lvl);
if (drift !== 0) {
out.push({ locationId: lvl.location?.id, locationName: lvl.location?.name, drift });
}
}
return out;
}
Flag it for review, do not touch the stock
When an item has a drift, add a review tag with tagsAdd. This script never calls inventorySetQuantities, because the right fix usually needs a person to decide whether on_hand, committed, or available was the wrong one to trust. Always read back userErrors. If Shopify refuses the tag, stop rather than pretend it worked.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_for_review(inventory_item_id, review_tag):
result = gql(TAGS_ADD, {"id": inventory_item_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function tagForReview(inventoryItemId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: inventoryItemId, tags: [reviewTag] })).tagsAdd;
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. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which items it would tag. Read the output, agree with it, then switch it off to let it write the tag. Run it on a schedule that matches how often your inventory apps write to Shopify, for example once a day.
Always start with DRY_RUN=true, and remember this script never edits a quantity, it only tags the item for a human to look at. Deciding whether on_hand, committed, or available was wrong needs the context a script does not have.
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 it only reads inventory and adds a review tag, it never writes a stock quantity.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Flag Shopify inventory levels where on hand, available, and committed do not add up.
A lot of stock bugs come from treating on_hand as if it were sellable. The two are
not the same. Shopify tracks on_hand (physical count), committed (reserved by open
orders), and available (what a customer can actually buy). The identity that must
hold at every location is:
on_hand - committed - damaged - safety_stock = available
When an app writes to the wrong bucket, or a manual adjustment only touches
on_hand, that identity breaks and the storefront can show stock that is not
really free, or hide stock that is. This reads each inventory item's quantities at
every location with the `quantities` field on InventoryLevel, works out the
expected available count in whole units (inventory counts are already integers,
so no minor-unit math is needed here), and tags the item for review with
tagsAdd when the drift is nonzero. Read only apart from the tag.
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("find_available_vs_committed_drift")
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"
REVIEW_TAG = os.environ.get("REVIEW_TAG", "inventory-drift")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
QUANTITY_NAMES = ["available", "on_hand", "committed", "damaged", "safety_stock"]
ITEMS_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id sku
inventoryItem {
id
tracked
inventoryLevels(first: 20) {
nodes {
location { id name }
quantities(names: ["available", "on_hand", "committed", "damaged", "safety_stock"]) {
name quantity
}
}
}
}
}
}
}"""
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { 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 quantities_by_name(level):
"""Turn the quantities list Shopify returns into a plain dict, missing names as 0."""
out = {name: 0 for name in QUANTITY_NAMES}
for q in level.get("quantities") or []:
if q.get("name") in out:
out[q["name"]] = q.get("quantity", 0)
return out
def drift_for_level(level):
"""Return the gap between the available Shopify reports and the available the
other buckets imply. Zero means the level ties out. Nonzero means something
wrote to on_hand, committed, damaged, or safety_stock without available
following, so the storefront number cannot be trusted.
"""
q = quantities_by_name(level)
expected_available = q["on_hand"] - q["committed"] - q["damaged"] - q["safety_stock"]
return q["available"] - expected_available
def levels_with_drift(inventory_item):
"""Return the locations on this inventory item where the drift is nonzero."""
out = []
for lvl in (inventory_item.get("inventoryLevels") or {}).get("nodes") or []:
drift = drift_for_level(lvl)
if drift != 0:
out.append({
"locationId": (lvl.get("location") or {}).get("id"),
"locationName": (lvl.get("location") or {}).get("name"),
"drift": drift,
})
return out
def tag_for_review(inventory_item_id, review_tag):
result = gql(TAGS_ADD, {"id": inventory_item_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def tracked_variants():
cursor = None
while True:
data = gql(ITEMS_QUERY, {"cursor": cursor})["productVariants"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
flagged = 0
for variant in tracked_variants():
item = variant.get("inventoryItem") or {}
if not item.get("tracked", True):
continue
drifts = levels_with_drift(item)
if not drifts:
continue
for d in drifts:
log.warning(
"Variant %s at %s is off by %s units. %s",
variant.get("sku") or variant["id"], d["locationName"], d["drift"],
"would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
tag_for_review(item["id"], REVIEW_TAG)
flagged += 1
log.info("Done. %d item(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Flag Shopify inventory levels where on hand, available, and committed do not add up.
*
* A lot of stock bugs come from treating on_hand as if it were sellable. The two are
* not the same. Shopify tracks on_hand (physical count), committed (reserved by open
* orders), and available (what a customer can actually buy). The identity that must
* hold at every location is:
*
* on_hand - committed - damaged - safety_stock = available
*
* When an app writes to the wrong bucket, or a manual adjustment only touches
* on_hand, that identity breaks and the storefront can show stock that is not
* really free, or hide stock that is. This reads each inventory item's quantities
* at every location with the `quantities` field on InventoryLevel, works out the
* expected available count, and tags the item for review with tagsAdd when the
* drift is nonzero. Run on a schedule.
*
* Guide: https://www.allanninal.dev/shopify/on-hand-vs-available-vs-committed/
*/
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 REVIEW_TAG = process.env.REVIEW_TAG || "inventory-drift";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const QUANTITY_NAMES = ["available", "on_hand", "committed", "damaged", "safety_stock"];
export function quantitiesByName(level) {
const out = Object.fromEntries(QUANTITY_NAMES.map((n) => [n, 0]));
for (const q of level.quantities || []) {
if (q.name in out) out[q.name] = q.quantity ?? 0;
}
return out;
}
export function driftForLevel(level) {
const q = quantitiesByName(level);
const expectedAvailable = q.on_hand - q.committed - q.damaged - q.safety_stock;
return q.available - expectedAvailable;
}
export function levelsWithDrift(inventoryItem) {
const out = [];
for (const lvl of inventoryItem.inventoryLevels?.nodes || []) {
const drift = driftForLevel(lvl);
if (drift !== 0) {
out.push({
locationId: lvl.location?.id,
locationName: lvl.location?.name,
drift,
});
}
}
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 ITEMS_QUERY = `
query($cursor: String) {
productVariants(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id sku
inventoryItem {
id
tracked
inventoryLevels(first: 20) {
nodes {
location { id name }
quantities(names: ["available", "on_hand", "committed", "damaged", "safety_stock"]) {
name quantity
}
}
}
}
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* trackedVariants() {
let cursor = null;
while (true) {
const data = (await gql(ITEMS_QUERY, { cursor })).productVariants;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function tagForReview(inventoryItemId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: inventoryItemId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let flagged = 0;
for await (const variant of trackedVariants()) {
const item = variant.inventoryItem || {};
if (item.tracked === false) continue;
const drifts = levelsWithDrift(item);
if (!drifts.length) continue;
for (const d of drifts) {
console.warn(
`Variant ${variant.sku || variant.id} at ${d.locationName} is off by ${d.drift} units. ${DRY_RUN ? "would tag" : "tagging"}`
);
}
if (!DRY_RUN) await tagForReview(item.id, REVIEW_TAG);
flagged++;
}
console.log(`Done. ${flagged} item(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The drift math is the part most worth testing, because it decides which items get flagged and which get left alone. Because we kept drift_for_level and levels_with_drift pure, the tests need no network and no Shopify account. They just feed in plain objects and check the answer.
from find_available_vs_committed_drift import (
quantities_by_name,
drift_for_level,
levels_with_drift,
)
def level(**over):
quantities = [
{"name": "available", "quantity": 10},
{"name": "on_hand", "quantity": 10},
{"name": "committed", "quantity": 0},
{"name": "damaged", "quantity": 0},
{"name": "safety_stock", "quantity": 0},
]
for name, value in over.items():
for q in quantities:
if q["name"] == name:
q["quantity"] = value
return {"location": {"id": "gid://shopify/Location/1", "name": "Warehouse"}, "quantities": quantities}
def test_quantities_by_name_fills_missing_with_zero():
lvl = {"quantities": [{"name": "available", "quantity": 5}]}
q = quantities_by_name(lvl)
assert q["available"] == 5
assert q["committed"] == 0
assert q["damaged"] == 0
def test_no_drift_when_available_matches_on_hand_minus_committed():
assert drift_for_level(level(on_hand=10, committed=4, available=6)) == 0
def test_drift_when_available_was_never_reduced_by_committed():
assert drift_for_level(level(on_hand=10, committed=4, available=10)) == 4
def test_drift_accounts_for_damaged_and_safety_stock():
assert drift_for_level(level(on_hand=20, committed=5, damaged=2, safety_stock=3, available=10)) == 0
assert drift_for_level(level(on_hand=20, committed=5, damaged=2, safety_stock=3, available=15)) == 5
def test_negative_drift_when_available_overcorrected():
assert drift_for_level(level(on_hand=10, committed=0, available=8)) == -2
def test_levels_with_drift_only_returns_offending_locations():
item = {
"inventoryLevels": {
"nodes": [
level(on_hand=10, committed=4, available=6),
level(on_hand=10, committed=4, available=10),
]
}
}
result = levels_with_drift(item)
assert len(result) == 1
assert result[0]["drift"] == 4
import { test } from "node:test";
import assert from "node:assert/strict";
import { quantitiesByName, driftForLevel, levelsWithDrift } from "./find-available-vs-committed-drift.js";
const level = (over = {}) => {
const quantities = [
{ name: "available", quantity: 10 },
{ name: "on_hand", quantity: 10 },
{ name: "committed", quantity: 0 },
{ name: "damaged", quantity: 0 },
{ name: "safety_stock", quantity: 0 },
];
for (const [name, value] of Object.entries(over)) {
const q = quantities.find((q) => q.name === name);
if (q) q.quantity = value;
}
return { location: { id: "gid://shopify/Location/1", name: "Warehouse" }, quantities };
};
test("quantitiesByName fills missing names with zero", () => {
const q = quantitiesByName({ quantities: [{ name: "available", quantity: 5 }] });
assert.equal(q.available, 5);
assert.equal(q.committed, 0);
});
test("no drift when available matches on_hand minus committed", () => {
assert.equal(driftForLevel(level({ on_hand: 10, committed: 4, available: 6 })), 0);
});
test("drift when available was never reduced by committed", () => {
assert.equal(driftForLevel(level({ on_hand: 10, committed: 4, available: 10 })), 4);
});
test("levelsWithDrift only returns offending locations", () => {
const item = {
inventoryLevels: {
nodes: [
level({ on_hand: 10, committed: 4, available: 6 }),
level({ on_hand: 10, committed: 4, available: 10 }),
],
},
};
const result = levelsWithDrift(item);
assert.equal(result.length, 1);
assert.equal(result[0].drift, 4);
});
Case studies
The apparel brand that oversold every restock
A clothing brand ran a nightly job that pulled the warehouse's physical count and pushed it straight into Shopify as available. Every night, committed stock from open wholesale orders got sold again to retail customers, and the fulfillment team spent every morning cancelling a handful of orders and apologizing.
Once the sync was pointed at on_hand instead of available, and the drift checker ran daily to catch anything still slipping through, the morning cancellations stopped. Committed stock finally stayed reserved for the order that actually claimed it.
The store with stock that would not come back
A homeware store used an app that committed inventory the moment a cart was created, to hold stock during checkout. When a cart was abandoned, the release did not always fire, so committed slowly climbed and available slowly shrank even though nothing had actually sold.
The drift script flagged the affected SKUs within a day because on_hand minus committed no longer matched what the storefront reported. That pointed the team straight at the checkout app's release logic instead of a guessing game across three different dashboards.
After this runs on a schedule, on_hand, committed, and available stay three honest numbers instead of one confused one. Nobody oversells stock that is already promised, nobody hides stock that a customer could buy, and when a bug does creep in, the review tag points you at the exact SKU and location before it turns into a cancelled order.
FAQ
What is the difference between on_hand and available inventory in Shopify?
on_hand is the physical count of a variant sitting at a location, whether or not it is free to sell. available is what a customer can actually buy right now. available is always on_hand minus committed minus damaged minus safety stock, never the raw physical count on its own.
What does committed mean in Shopify inventory?
committed is the quantity reserved by orders that exist but have not shipped yet. It is subtracted from on_hand to work out available, so a unit can be sitting on the shelf, counted in on_hand, and still not sellable because it is already committed to another order.
Why would available and on_hand stop matching in Shopify?
They are supposed to differ by design once anything is committed, damaged, or held as safety stock. The real bug is when the identity on_hand minus committed minus damaged minus safety stock no longer equals available, usually because an app or a manual edit wrote to one bucket with inventorySetQuantities and skipped adjusting the others.
Related field notes
Citations
On the problem:
- Shopify Help Center: understanding inventory quantities, including on hand, available, and committed. help.shopify.com/en/manual/products/inventory/getting-started-with-inventory/inventory-quantities
- Shopify Help Center: how committed inventory is reserved by open orders. help.shopify.com/en/manual/products/inventory/getting-started-with-inventory
- Shopify Community: apps and integrations confusing on_hand with available when syncing stock. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
InventoryLevelobject and itsquantitiesfield. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryLevel - Shopify Admin GraphQL: the
InventoryItemobject and itsinventoryLevelsconnection. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryItem - Shopify Admin GraphQL: the
tagsAddmutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd
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 clear up your stock numbers?
If this saved you an oversold order or a wrong reorder decision, 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