Let each component decide whether stock blocks the sale
If your app assembles products out of other products — a kit, a bundle, a build-to-order configuration — something has to decide how many of the assembled thing a shop can sell. The obvious answer is "however many the scarcest component allows". That answer is wrong for every component the shop never actually runs out of, and Shopify's default quietly makes it wrong in the worst direction: inventoryPolicy defaults to DENY, so a component sitting at zero stock makes everything built from it unsellable, without anybody choosing that.
This is a follow-along. By the end, each component declares whether its stock is real scarcity, your buildable-quantity math honours that, and the values in Shopify agree with the values in your database.
If your app sells only what it stocks one-for-one, you don't need any of this.
Before you start
- An embedded Shopify admin app with the Admin GraphQL API working from a loader or action, and
write_products+write_inventoryscopes. → Shopify: build an admin app - Products with inventory tracked at a location. Untracked variants have no quantity to read. → Shopify: inventory in the Admin API
What you're building
Two things, and the order matters:
- A backfill that pushes SKU, price, inventory policy and quantity from your database up to Shopify — so the two stop disagreeing.
- A rule: only components on
DENYlimit the build.CONTINUEcomponents never bottleneck, even at zero.
The backfill comes first because of a trap in step 5: if Shopify holds different values from your database, the products/update webhook resolves that disagreement in Shopify's favour, and your careful DB writes vanish on the shop's next product edit.
Step 1 — Read what Shopify currently holds
You cannot send only the differences until you know the current state. One query per batch of product ids:
query ComponentBackfillState($ids: [ID!]!) {
nodes(ids: $ids) {
... on Product {
id
title
variants(first: 1) {
edges {
node {
id
price
inventoryPolicy
inventoryItem {
id
sku
tracked
inventoryLevels(first: 1) {
edges {
node {
quantities(names: ["available"]) { name quantity }
location { id }
}
}
}
}
}
}
}
}
}
}available is one of several named quantities — you have to ask for it by name and dig it back out of the array.
Step 2 — Push only what differs
Build the input field by field, and skip the call entirely when nothing changed. That is what makes the backfill re-runnable and an interrupted run safe to just repeat:
const variantInput: Record<string, unknown> = { id: current.variantId };
const inventoryItemInput: Record<string, unknown> = {};
let changed = false;
if (!blank(local.price) && !samePrice(local.price, current.price)) {
variantInput.price = parseFloat(local.price).toFixed(2);
changed = true;
}
if (current.policy !== "CONTINUE") {
variantInput.inventoryPolicy = "CONTINUE";
changed = true;
}
if (!blank(local.sku) && local.sku !== current.sku) {
inventoryItemInput.sku = local.sku; // SKU lives on the inventory item, not the variant
changed = true;
}
if (current.tracked === false) inventoryItemInput.tracked = true;
if (Object.keys(inventoryItemInput).length > 0) {
variantInput.inventoryItem = inventoryItemInput;
changed = true;
}Then one mutation per product:
mutation ProductVariantsBulkUpdateComponents($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
productVariants { id }
userErrors { field message }
}
}Read userErrors on every response. A 200 with a populated userErrors array is the normal way this mutation refuses you.
Step 3 — Quantities need a second mutation
This is the step people lose an hour to. ProductVariantsBulkInput has an inventoryQuantities field, and the schema tells you exactly why you can't use it here:
The inventory quantities at each location where the variant is stocked. […] Supported as input with the
productVariantsBulkCreatemutation only.
So quantities go up separately — and that mutation takes an array, which means unrelated products ride along in one call:
mutation InventorySetQuantitiesComponents($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { createdAt reason }
userErrors { field message }
}
}variables: {
input: {
name: "available",
reason: "correction",
ignoreCompareQuantity: true,
quantities: chunk, // [{ inventoryItemId, locationId, quantity }, …], batched at 100
},
}ignoreCompareQuantity: true is what makes this a backfill rather than an optimistic update. Leave it out and you must send the quantity you believe is currently there, and every stale read fails the call.
Step 4 — Make the policy the authority in your own math
Now the actual rule. A component on CONTINUE has effectively unlimited supply, so it is skipped entirely — it can never be the bottleneck. Only DENY components contribute floor(available / usage):
for (const [componentId, usage] of usageByComponent) {
const info = inventory.get(componentId);
if (!info) continue;
// CONTINUE = unlimited supply: never gates the build, never the bottleneck.
if (info.policy === "CONTINUE") continue;
const buildable = Math.floor(info.available / usage);
if (buildable < quantity) {
quantity = buildable;
bottleneck = componentId;
}
}And the case that falls out of it: when every component is CONTINUE, nothing about the assembled product is supply-limited — so it gets CONTINUE too, and its quantity stops meaning anything.
const assembledPolicy = allComponentsUnlimited ? "CONTINUE" : "DENY";The check that matters, because this is the case the default gets wrong:
it('a CONTINUE component never bottlenecks, even at zero on hand', () => {
const result = calculateBuildableQuantity({
usage: { unlimited: 1, scarce: 1 },
inventory: new Map([
['unlimited', { available: 0, policy: 'CONTINUE' }],
['scarce', { available: 6, policy: 'DENY' }],
]),
});
// Without the policy rule the zero-stock component would drag this to 0.
expect(result.quantity).toBe(6);
});If your app has two places that compute this — say a background pricing job and a save path — they must learn the rule together. Two implementations that disagree means the answer depends on which ran last.
Where you can see it worked
Run the backfill and read the counts back:
Variants updated: 139. Quantities set: 139. Already correct: 3. Failed: 0.Run it a second time without changing anything:
Variants updated: 0. Quantities set: 0. Already correct: 142. Failed: 0.Then go look at the thing that was broken: take a component whose stock is zero, confirm it's on CONTINUE, and open an assembled product that uses it on the storefront. It's buyable. Before the rule, it was silently unsellable and nothing anywhere said why.
Gotchas
DENYis the default and it is silent. A variant that never set a policy is onDENY. Nobody chose it; it just blocks.- Push to Shopify before you trust your database. If a
products/updatewebhook handler copies Shopify's values down, every value you wrote straight to the DB is one product edit away from being overwritten. After the backfill the handler is a no-op, which is the point. - Check for title drift before running. Touching the product fires
products/update, and if your handler writes Shopify's title back down, any titles that differ get overwritten as a side effect of a backfill that was never about titles. Count them first and say so. - SKU is on
inventoryItem, not the variant. It goes in the nestedinventoryIteminput. - A variant with no inventory level has no location id. Skip its quantity rather than guessing a location.
- Anywhere you create or republish a product, use the same policy. One hardcoded
DENYin a publish path undoes the backfill one product at a time.
Docs
productVariantsBulkUpdate→ https://shopify.dev/docs/api/admin-graphql/2025-04/mutations/productVariantsBulkUpdateinventorySetQuantities→ https://shopify.dev/docs/api/admin-graphql/2025-04/mutations/inventorySetQuantitiesProductVariantInventoryPolicy→ https://shopify.dev/docs/api/admin-graphql/2025-04/enums/ProductVariantInventoryPolicy
Implementation prompt
I have an embedded Shopify admin app (Remix + TypeScript, Admin GraphQL API version 2025-04)
with write_products and write_inventory scopes, and products whose inventory is tracked at a
location. Check these prerequisites first and set up anything missing before you start.
Build two things:
A. A re-runnable backfill service that, for each of my products, reads the current variant
state from Shopify (price, inventoryPolicy, inventoryItem sku/tracked, and the "available"
quantity with its location id), compares it to my database, and pushes ONLY the fields that
differ. It must support a dry run that reports what would change without writing, and
return counts of updated / already-correct / failed.
B. A pure function `calculateBuildableQuantity` that, given how many of each component an
assembled product uses and each component's { available, policy }, returns how many can be
built and which component is the bottleneck. Cover it with unit tests.
Do not invent my data model, my product taxonomy, or my UI. Read the local values from a
repository/service interface you stub, and leave the schema to me.
Constraints — these are the traps:
- inventoryPolicy defaults to DENY. Treat "no policy set" as DENY, and make CONTINUE explicit.
- A CONTINUE component has unlimited supply: skip it entirely in the bottleneck math, even at
zero available. Only DENY components contribute floor(available / usage).
- If every component used is CONTINUE, the assembled product itself must be set to CONTINUE.
- productVariantsBulkUpdate CANNOT carry inventory quantities on 2025-04 — inventoryQuantities
is supported on productVariantsBulkCreate only. Use a separate inventorySetQuantities call.
- inventorySetQuantities takes an array: batch it (100 per call) rather than one call per item.
- Pass ignoreCompareQuantity: true on the backfill, or a stale read fails the write.
- sku and tracked live on inventoryItem, not on the variant.
- Read the "available" quantity by name out of the quantities array; it is not a scalar field.
- Every mutation returns userErrors on a 200. Check it on every response and count failures.
- Send only differing fields, so a second run reports zero updates and an interrupted run can
simply be repeated.
- If a variant has no inventory level, it has no location id: skip its quantity, do not guess.
Acceptance check: run the backfill twice. The first run reports updates; the second reports
zero updated and everything already correct. Then set one component to 0 available with policy
CONTINUE — calculateBuildableQuantity must ignore it entirely, and the assembled product must
stay buyable. Tell me if either fails.Verified against Admin GraphQL 2025-04 (productVariantsBulkUpdate, inventorySetQuantities) — backfill run on a dev store, 139 products updated, 3 already correct, 0 failed.