Shopify changeFromQuantity: Update Stock Safely

changeFromQuantity is how Shopify’s inventory mutations do compare-and-swap. You pass the quantity you believe is currently at that location, and the write fails with CHANGE_FROM_QUANTITY_STALE if the real number has moved. From API version 2026-04 you have to pass it on every line, even when the value is null.

It replaced two older fields, and it arrived alongside a second mandatory change to the same mutations. If you sync stock from an ERP or a warehouse system and have not touched the integration since 2025, both will break it the moment you move to 2026-04 or later.

What replaced compareQuantity in the Shopify inventory API?

Shopify posted the plan on 12 December 2025. Version 2026-01 added changeFromQuantity to InventoryChangeInput, InventoryMoveQuantityTerminalInput, InventorySetQuantityInput, and the InventoryAdjustmentInput used by productVariantsBulkUpdate. In that version it was optional: the mutation fell back to the legacy fields when you left it out, so nothing broke yet.

Two things changed in 2026-04. compareQuantity and ignoreCompareQuantity were removed from inventorySetQuantities, and changeFromQuantity became required. Required here means present in the request. Passing null counts; leaving the field out does not. Shopify’s wording is that “calling these mutations without passing in null or passing in the current actual quantity will result in an error at runtime”.

Send the old field to 2026-07 and you do not even reach the runtime. Shopify’s schema validator answers:

Field "compareQuantity" is not defined by type "InventoryQuantityInput". Did you mean "quantity"?

ignoreCompareQuantity gets the same treatment against InventorySetQuantitiesInput. The opt-out is now changeFromQuantity: null, written out on every line of the batch.

How do you read the current quantity before setting it?

Compare-and-swap only works if you have something to compare against, so a set is really a read then a write. inventoryLevels on the item gives you both numbers per location in one hop:

query StockByLocation($id: ID!) {
  inventoryItem(id: $id) {
    id
    inventoryLevels(first: 10) {
      nodes {
        location { id name }
        quantities(names: ["available", "on_hand"]) { name quantity }
      }
    }
  }
}

The names argument takes quantity names, and Shopify documents eight states: incoming, on_hand, available, committed, reserved, damaged, safety_stock and quality_control. on_hand is “the total number of units that are physically stocked at a location”. available is the stock ready to sell or fulfil. committed is managed by Shopify through actions such as creating and fulfilling orders, and you cannot adjust or move it through the Admin API.

One trap while you are in here. InventoryItem.variant is deprecated in 2026-07 in favour of variants, so a query written against an older version may validate with a warning you have stopped reading.

What does an idempotent inventory write look like?

The same 2026-04 release made the @idempotent directive mandatory on a list of inventory and refund mutations. inventorySetQuantities and inventoryAdjustQuantities are on it, along with inventoryMoveQuantities, inventorySetOnHandQuantities, inventoryActivate, locationActivate, locationDeactivate, the shipment and transfer mutations, and refundCreate. The directive does not show up as mandatory at the schema level, so the request validates cleanly and then fails when it runs. Schema-driven tooling will not flag the omission for you.

mutation SetOnHand($input: InventorySetQuantitiesInput!, $key: String!) {
  inventorySetQuantities(input: $input) @idempotent(key: $key) {
    inventoryAdjustmentGroup {
      id
      reason
      changes { name delta quantityAfterChange }
    }
    userErrors { field message code }
  }
}

For inventorySetQuantities the input name accepts only available or on_hand. reason has to come from Shopify’s fixed list, which includes correction, cycle_count_available, received, restock, shrinkage and damaged, and referenceDocumentUri is the free-form pointer back at whatever in your system caused the change.

Keys are tracked for 24 hours from the original request. Shopify recommends a UUID, v4 or v7. For a deterministic key, its guidance is UUID v5 generated from the operation’s own parameters. A repeat of a successful request with the same key gets the cached GraphQL response back without the operation running again.

What happens when changeFromQuantity is stale?

You get a userErrors entry with code CHANGE_FROM_QUANTITY_STALE, and the quantity is left alone. That is the field doing its job. The interesting part is what your client does next.

import { randomUUID } from "node:crypto";

type Line = { inventoryItemId: string; locationId: string; quantity: number };

async function setOnHand(line: Line, attempts = 3): Promise<void> {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    const current = await readOnHand(line.inventoryItemId, line.locationId);
    if (current === line.quantity) return;

    const data = await admin(SET_ON_HAND, {
      key: randomUUID(),
      input: {
        name: "on_hand",
        reason: "correction",
        referenceDocumentUri: `gid://erp-connector/StockSync/${line.inventoryItemId}`,
        quantities: [{ ...line, changeFromQuantity: current }]
      }
    });

    const errors = data.inventorySetQuantities.userErrors;
    if (errors.length === 0) return;
    if (!errors.some((e: { code: string }) => e.code === "CHANGE_FROM_QUANTITY_STALE")) {
      throw new Error(errors.map((e: { message: string }) => e.message).join("; "));
    }
  }
  throw new Error(`on_hand for ${line.inventoryItemId} is still contended`);
}

Note the randomUUID() inside the loop. A retry after a stale error carries a different changeFromQuantity, so the parameters have changed, and reusing the first key earns you IDEMPOTENCY_KEY_PARAMETER_MISMATCH instead of a write. The key protects a single attempt against a dropped connection. It does not identify the business intent across attempts.

The early return matters too. If the read already matches your target, skip the mutation. A set that changes nothing still writes an adjustment record and still spends a request against the cost-based throttle.

Two more idempotency codes sit in the same enum. IDEMPOTENCY_CONCURRENT_REQUEST means another request with the same key is still being processed, and IDEMPOTENCY_PREVIOUS_ATTEMPT_FAILED speaks for itself. Shopify also documents domain-specific NOT_FOUND errors on repeat requests, such as LOCATION_NOT_FOUND, meaning the original request completed but the business data behind it was deleted afterwards. Treat that one as done.

Why does the reference page still describe compareQuantity?

Because the description has not caught up with the schema. As of September 2026, the description shopify.dev prints for inventorySetQuantities still reads: “If ignoreCompareQuantity is not set to true, the mutation will only update the quantity if the persisted quantity matches the compareQuantity value.” The InventoryQuantityInput field list on the same site names four fields: changeFromQuantity, inventoryItemId, locationId and quantity. Neither of the two in that sentence is among them.

Follow the schema. Where a mutation’s narrative description and its input object disagree, the input object is the thing your request is validated against, and the changelog entries above confirm which way the removal went. The guide page for managing quantities and states already uses changeFromQuantity in its worked example. There is one syntax; the mutation’s description has not been updated to match it. The InventorySetQuantitiesUserErrorCode enum still carries COMPARE_QUANTITY_REQUIRED and COMPARE_QUANTITY_STALE alongside CHANGE_FROM_QUANTITY_STALE, which is the same lag showing up somewhere you might act on it.

When should you adjust instead of set?

Use inventoryAdjustQuantities when your system knows the movement and not the total. A goods-in scan knows three units arrived. It does not know what the shelf held beforehand.

mutation AdjustAvailable($input: InventoryAdjustQuantitiesInput!, $key: String!) {
  inventoryAdjustQuantities(input: $input) @idempotent(key: $key) {
    inventoryAdjustmentGroup {
      id
      changes { name delta quantityAfterChange }
    }
    userErrors { field message code }
  }
}

InventoryChangeInput takes delta where the set mutation takes quantity, and it carries changeFromQuantity under the same rules. It also takes ledgerDocumentUri, which has to be a valid non-Shopify URI, cannot use the gid://shopify/* format, and is required for every quantity name except available. Shopify’s suggested shape is gid://[your-app-name]/[transaction-type]/[id]. The adjustable names are available, damaged, quality_control, reserved and safety_stock.

Shopify’s own advice is to use inventorySetQuantities only when you are calling on behalf of a system that acts as the source of truth for inventory quantities. The reason is mechanical. A set overwrites whatever Shopify’s order flow just did to that number, and changeFromQuantity is what turns a silent overwrite into an error you can see.

If you are building the integration today, set on_hand from your ERP with a real changeFromQuantity, leave available to Shopify, and generate a fresh idempotency key per HTTP attempt. Retry stale errors three times with a fresh read each time, then log the SKU and the location and stop. A line that loses three races in a row probably has another writer you have not accounted for, and looping will not tell you which. Pass changeFromQuantity: null only where nothing else writes that number, which rules out anything Shopify’s order flow touches. Moving thousands of lines at once is a separate path, and our guide to Admin API bulk operations covers it. The same idempotency thinking applies to inbound events, which we worked through in the post on verifying and deduplicating Shopify webhooks.

Whoooop builds and maintains Shopify integrations that have to agree with a warehouse system at the end of every day. Stock drift and an overdue API version bump are both ordinary Shopify development work for us.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch