Diagnostic Inventory
Shopify untracked items sell without limit
A product keeps selling no matter how many orders come in. There is no oversold warning, no negative number, nothing. That is because the variant was never tracking inventory in the first place, so Shopify has no stock count to run out of. The buy button just stays on forever. Here is why that happens and a small script that finds the variants quietly running without a stock count and turns tracking on for the ones that should have one.
When a variant's inventoryItem.tracked field is false, Shopify keeps no quantity for it at all, so nothing ever counts down and nothing ever runs out. Run a small Python or Node.js script that lists variants, reads inventoryItem.tracked alongside recent order volume, and turns tracking on with inventoryItemUpdate for the ones you have confirmed with a tag are meant to carry real stock. Full code, tests, and a dry run guard are below.
The problem in plain words
Every Shopify variant has an inventory item behind it, and that inventory item has a single switch called tracked. When it is on, Shopify keeps a quantity at every location, subtracts one each time an order is placed, and can show an oversold warning or block the sale once stock hits zero.
When tracked is off, none of that happens. There is no quantity to read, nothing to subtract from, and no floor to hit. The product page shows the item as available because, as far as Shopify is concerned, availability was never tied to a count. A one-of-a-kind vintage find, a custom cake with limited slots, or a physical product that someone set up in a hurry can all end up selling far past what actually exists on the shelf, and nobody notices until the fulfillment team goes looking for units that are not there.
Why it happens
Shopify defaults new variants added through certain flows to tracked, but plenty of paths leave tracking off, and once it is off nothing on the storefront tells you. A few common ways stores end up here:
- A product was imported through a CSV, an app, or the legacy REST endpoint that did not set
inventory_management, so the variant came in untracked by default. - A merchant turned tracking off on purpose for a one-off item to sell it a single time, then reused that same product as a template for new physical products.
- A print-on-demand or dropshipping app manages stock on its own side and intentionally leaves Shopify's own tracking off, but the supplier relationship ends and nobody flips it back on.
- A staff member unchecked "Track quantity" while cleaning up a listing and never rechecked it, so a normal stocked item quietly lost its stock count.
This is easy to miss because the product page looks completely normal. There is no oversold badge, no negative number, nothing that says "this is different." The only tell is that the count next to Inventory in the admin is simply missing, and orders for that variant never slow down no matter how many come in. See the citations at the end for the exact docs.
Untracked is a valid state for plenty of products, like services, gift cards, or digital downloads that were never meant to run out. So the fix is not "track everything." It is "flag the untracked variants that look like real physical stock and let a human decide." We do that by pairing inventoryItem.tracked with recent order volume and a confirmation tag, so the script only turns tracking on for the ones you have actually reviewed.
The fix, as a flow
We do not touch checkout or pricing. We add a job that lists variants, reads whether each one is tracked and how many times it has sold recently, and turns tracking on only for the ones that are both untracked and carry your confirmation tag. Everything else, including genuine services and digital items, 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, write_products, and read_orders 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 TRACK_FIX_TAG="track-me"
export MIN_RECENT_SALES="1"
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 TRACK_FIX_TAG="track-me"
export MIN_RECENT_SALES="1"
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 variants and to run the 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 variants and their inventory items
Ask for product variants and read back the fields the decision needs: the inventory item id, whether it is tracked, the product tags, and a rough count of recent sales for that variant. We page through with a cursor so the job handles a large catalog.
VARIANTS_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor, query: "inventory_total:0") {
pageInfo { hasNextPage endCursor }
nodes {
id
product { tags }
inventoryItem { id tracked }
}
}
}"""
def candidate_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_total:0") {
pageInfo { hasNextPage endCursor }
nodes {
id
product { tags }
inventoryItem { id tracked }
}
}
}`;
async function* candidateVariants() {
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;
}
}
Count recent sales per variant
An untracked variant with zero recent orders is probably fine to leave alone, it might just be a new or slow listing. What we care about is untracked variants that keep selling. A short lookback query on line items tells us how many units of each variant sold recently, in whole units, no money math needed here.
def recent_sales_count(variant_id, lookback_days):
"""Units of this variant sold in the lookback window, from recent orders."""
q = f"created_at:>-{lookback_days}d"
cursor = None
total = 0
while True:
data = gql(RECENT_ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for order in data["nodes"]:
for item in order["lineItems"]["nodes"]:
if item.get("variant", {}).get("id") == variant_id:
total += item["quantity"]
if not data["pageInfo"]["hasNextPage"]:
return total
cursor = data["pageInfo"]["endCursor"]
async function recentSalesCount(variantId, lookbackDays) {
// Units of this variant sold in the lookback window, from recent orders.
const q = `created_at:>-${lookbackDays}d`;
let cursor = null;
let total = 0;
while (true) {
const data = (await gql(RECENT_ORDERS_QUERY, { cursor, q })).orders;
for (const order of data.nodes) {
for (const item of order.lineItems.nodes) {
if (item.variant?.id === variantId) total += item.quantity;
}
}
if (!data.pageInfo.hasNextPage) return total;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a variant, its recent sales count, and the required tag, and returns true or false. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The inventory item must be untracked, the variant must have sold at least the configured minimum recently, and the product must carry the confirmation tag. If any of those is missing, we do not touch the variant.
def eligible_to_track(variant, recent_sales, required_tag, min_sales=1):
inventory_item = variant.get("inventoryItem") or {}
if inventory_item.get("tracked"):
return False
if recent_sales < min_sales:
return False
tags = (variant.get("product") or {}).get("tags") or []
return required_tag in tags
export function eligibleToTrack(variant, recentSales, requiredTag, minSales = 1) {
const inventoryItem = variant.inventoryItem || {};
if (inventoryItem.tracked) return false;
if (recentSales < minSales) return false;
const tags = variant.product?.tags || [];
return tags.includes(requiredTag);
}
Turn tracking on the way the admin toggle would
When a variant is eligible, call the inventoryItemUpdate mutation on its inventory item id with tracked: true. Shopify starts keeping a quantity for it from that point on. Always read back userErrors. If Shopify refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
TRACK_MUTATION = """
mutation($id: ID!, $input: InventoryItemInput!) {
inventoryItemUpdate(id: $id, input: $input) {
inventoryItem { id tracked }
userErrors { field message }
}
}"""
def turn_tracking_on(inventory_item_id):
result = gql(TRACK_MUTATION, {"id": inventory_item_id, "input": {"tracked": True}})["inventoryItemUpdate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["inventoryItem"]["tracked"]
const TRACK_MUTATION = `
mutation($id: ID!, $input: InventoryItemInput!) {
inventoryItemUpdate(id: $id, input: $input) {
inventoryItem { id tracked }
userErrors { field message }
}
}`;
async function turnTrackingOn(inventoryItemId) {
const result = (await gql(TRACK_MUTATION, { id: inventoryItemId, input: { tracked: true } })).inventoryItemUpdate;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.inventoryItem.tracked;
}
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 variants it would switch on. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often new products get added, for example once a day.
Always start with DRY_RUN=true, and only tag a product once a human has confirmed it is a real physical item that should carry a stock count. Turning tracking on for a genuine service or digital product would wrongly cap something that never needed a limit.
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 touches variants that are untracked, have recent sales, and carry your confirmation tag.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Turn inventory tracking back on for Shopify variants that sell without limit.
A variant with inventoryItem.tracked set to false has no quantity behind it, so
it never runs out no matter how many orders come in. Some untracked variants are
meant to be that way, like services or digital goods, so this only turns tracking
on for variants that are untracked, have real recent sales, and carry a confirmation
tag you add once you have reviewed them. 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_untracked_items")
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"
TRACK_TAG = os.environ.get("TRACK_FIX_TAG", "track-me")
MIN_RECENT_SALES = int(os.environ.get("MIN_RECENT_SALES", "1"))
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
VARIANTS_QUERY = """
query($cursor: String) {
productVariants(first: 50, after: $cursor, query: "inventory_total:0") {
pageInfo { hasNextPage endCursor }
nodes {
id
product { tags }
inventoryItem { id tracked }
}
}
}"""
RECENT_ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 50, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
lineItems(first: 50) {
nodes { quantity variant { id } }
}
}
}
}"""
TRACK_MUTATION = """
mutation($id: ID!, $input: InventoryItemInput!) {
inventoryItemUpdate(id: $id, input: $input) {
inventoryItem { id tracked }
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 eligible_to_track(variant, recent_sales, required_tag, min_sales=1):
inventory_item = variant.get("inventoryItem") or {}
if inventory_item.get("tracked"):
return False
if recent_sales < min_sales:
return False
tags = (variant.get("product") or {}).get("tags") or []
return required_tag in tags
def candidate_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 recent_sales_count(variant_id, lookback_days):
q = f"created_at:>-{lookback_days}d"
cursor = None
total = 0
while True:
data = gql(RECENT_ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for order in data["nodes"]:
for item in order["lineItems"]["nodes"]:
if item.get("variant", {}).get("id") == variant_id:
total += item["quantity"]
if not data["pageInfo"]["hasNextPage"]:
return total
cursor = data["pageInfo"]["endCursor"]
def turn_tracking_on(inventory_item_id):
result = gql(TRACK_MUTATION, {"id": inventory_item_id, "input": {"tracked": True}})["inventoryItemUpdate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["inventoryItem"]["tracked"]
def run():
fixed = 0
for variant in candidate_variants():
sales = recent_sales_count(variant["id"], LOOKBACK_DAYS)
if not eligible_to_track(variant, sales, TRACK_TAG, MIN_RECENT_SALES):
continue
log.info("Variant %s untracked with %d recent sales. %s",
variant["id"], sales, "would turn tracking on" if DRY_RUN else "turning tracking on")
if not DRY_RUN:
turn_tracking_on(variant["inventoryItem"]["id"])
fixed += 1
log.info("Done. %d variant(s) %s.", fixed, "to fix" if DRY_RUN else "fixed")
if __name__ == "__main__":
run()
/**
* Turn inventory tracking back on for Shopify variants that sell without limit.
*
* A variant with inventoryItem.tracked set to false has no quantity behind it, so
* it never runs out no matter how many orders come in. Some untracked variants are
* meant to be that way, like services or digital goods, so this only turns tracking
* on for variants that are untracked, have real recent sales, and carry a confirmation
* tag you add once you have reviewed them. 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 TRACK_TAG = process.env.TRACK_FIX_TAG || "track-me";
const MIN_RECENT_SALES = Number(process.env.MIN_RECENT_SALES || 1);
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function eligibleToTrack(variant, recentSales, requiredTag, minSales = 1) {
const inventoryItem = variant.inventoryItem || {};
if (inventoryItem.tracked) return false;
if (recentSales < minSales) return false;
const tags = variant.product?.tags || [];
return tags.includes(requiredTag);
}
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_total:0") {
pageInfo { hasNextPage endCursor }
nodes {
id
product { tags }
inventoryItem { id tracked }
}
}
}`;
const RECENT_ORDERS_QUERY = `
query($cursor: String, $q: String!) {
orders(first: 50, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
lineItems(first: 50) {
nodes { quantity variant { id } }
}
}
}
}`;
const TRACK_MUTATION = `
mutation($id: ID!, $input: InventoryItemInput!) {
inventoryItemUpdate(id: $id, input: $input) {
inventoryItem { id tracked }
userErrors { field message }
}
}`;
async function* candidateVariants() {
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 recentSalesCount(variantId, lookbackDays) {
const q = `created_at:>-${lookbackDays}d`;
let cursor = null;
let total = 0;
while (true) {
const data = (await gql(RECENT_ORDERS_QUERY, { cursor, q })).orders;
for (const order of data.nodes) {
for (const item of order.lineItems.nodes) {
if (item.variant?.id === variantId) total += item.quantity;
}
}
if (!data.pageInfo.hasNextPage) return total;
cursor = data.pageInfo.endCursor;
}
}
async function turnTrackingOn(inventoryItemId) {
const result = (await gql(TRACK_MUTATION, { id: inventoryItemId, input: { tracked: true } })).inventoryItemUpdate;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.inventoryItem.tracked;
}
export async function run() {
let fixed = 0;
for await (const variant of candidateVariants()) {
const sales = await recentSalesCount(variant.id, LOOKBACK_DAYS);
if (!eligibleToTrack(variant, sales, TRACK_TAG, MIN_RECENT_SALES)) continue;
console.log(`Variant ${variant.id} untracked with ${sales} recent sales. ${DRY_RUN ? "dry run" : "turning tracking on"}`);
if (!DRY_RUN) await turnTrackingOn(variant.inventoryItem.id);
fixed++;
}
console.log(`Done. ${fixed} variant(s) ${DRY_RUN ? "to fix" : "fixed"}.`);
}
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 whether a real variant gets a stock count enforced on it. Because we kept eligible_to_track pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from fix_untracked_items import eligible_to_track
def variant(**over):
base = {
"inventoryItem": {"id": "gid://shopify/InventoryItem/1", "tracked": False},
"product": {"tags": ["track-me"]},
}
base.update(over)
return base
def test_eligible_when_untracked_sold_and_tagged():
assert eligible_to_track(variant(), 3, "track-me") is True
def test_skip_when_already_tracked():
v = variant(inventoryItem={"id": "gid://shopify/InventoryItem/1", "tracked": True})
assert eligible_to_track(v, 3, "track-me") is False
def test_skip_when_no_recent_sales():
assert eligible_to_track(variant(), 0, "track-me") is False
def test_skip_without_tag():
v = variant(product={"tags": []})
assert eligible_to_track(v, 3, "track-me") is False
def test_respects_custom_minimum_sales():
assert eligible_to_track(variant(), 2, "track-me", min_sales=5) is False
assert eligible_to_track(variant(), 5, "track-me", min_sales=5) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { eligibleToTrack } from "./fix-untracked-items.js";
const variant = (over = {}) => ({
inventoryItem: { id: "gid://shopify/InventoryItem/1", tracked: false },
product: { tags: ["track-me"] },
...over,
});
test("eligible when untracked, sold, and tagged", () => {
assert.equal(eligibleToTrack(variant(), 3, "track-me"), true);
});
test("skip when already tracked", () => {
const v = variant({ inventoryItem: { id: "gid://shopify/InventoryItem/1", tracked: true } });
assert.equal(eligibleToTrack(v, 3, "track-me"), false);
});
test("skip when no recent sales", () => {
assert.equal(eligibleToTrack(variant(), 0, "track-me"), false);
});
test("skip without tag", () => {
const v = variant({ product: { tags: [] } });
assert.equal(eligibleToTrack(v, 3, "track-me"), false);
});
test("respects custom minimum sales", () => {
assert.equal(eligibleToTrack(variant(), 2, "track-me", 5), false);
assert.equal(eligibleToTrack(variant(), 5, "track-me", 5), true);
});
Case studies
The furniture store with no stock ceiling
A furniture brand migrated its catalog with a CSV importer that skipped the inventory management column on a batch of variants. The items looked normal on the storefront, but a handful of solid wood pieces that only ever had one unit in the warehouse sold eleven times over in a single month.
The team tagged the affected products track-me once they confirmed each one really was meant to carry a stock count. The script ran in dry run, listed exactly the variants with real sales and no tracking, and once switched to write mode it turned tracking on for all of them in one pass.
The studio that copied a service listing into a product
A print shop duplicated a one-off custom order listing, which had tracking off on purpose, to quickly launch a new physical product. Nobody remembered to turn tracking back on, and the new item sold well past the fifty units the shop actually had printed.
Because the script only acts on variants tagged as reviewed, the studio's other genuinely untracked listings, like rush design services, were never touched. Only the mistakenly copied physical product got its stock count switched back on.
After this runs on a schedule, a variant that quietly lost its stock count gets caught within a day instead of after a warehouse audit turns up a shortfall. Genuine services and digital items stay untracked because the script never touches anything without your confirmation tag. Keep that tag step with a human, since that is what keeps the script from capping something that was never supposed to run out.
FAQ
Why does a Shopify item never run out of stock?
If inventory tracking is turned off for that variant, Shopify never keeps a quantity for it at all, so there is nothing to run out. The buy button stays available no matter how many orders come in, because Shopify is not counting units against a stock number.
How do I find Shopify variants that are not tracking inventory?
Query products and their variants for inventoryItem.tracked. Any variant where tracked is false has no stock count backing it. List those alongside recent order volume so you can tell which ones are genuinely untracked on purpose, like services, and which ones are a real physical product that was set up wrong.
Is it safe to turn tracking on automatically with a script?
It is safe when the script only flips tracking on for variants you have marked eligible, for example by requiring a confirmation tag on the product, and when it runs in dry run first. Turning tracking on for a genuine service or digital item would wrongly cap something that never needed a stock count, so the decision needs a human in the loop.
Related field notes
Citations
On the problem:
- Shopify Help Center: tracking inventory quantities for a product. help.shopify.com/en/manual/products/inventory/getting-started-with-inventory/set-up-inventory-tracking
- Shopify Help Center: understanding inventory management options per variant. help.shopify.com/en/manual/products/inventory
- Shopify Community: variant sells with no stock limit because tracking is off. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
inventoryItemUpdatemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/inventoryItemUpdate - Shopify Admin GraphQL: the InventoryItem object, including
tracked. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryItem - 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 catch a variant selling without limit?
If this saved you from a stock surprise or an oversold custom order, 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