Auto-update price and compare-at price when supplier cost changes

September 3, 2026

Your supplier raises the cost of a variant from 4.00 to 5.20. Nothing in Shopify reacts. The retail price stays where it was, and the margin you planned quietly disappears until someone notices during a stocktake weeks later.

Shopify Flow cannot help on its own here, because Shopify has no built-in trigger for a change to cost per item. It also has no action that recalculates a price from one.

This guide closes both gaps. When cost per item changes on any variant, a Flow starts, and a short function writes the new price and compare-at price back to Shopify. It takes about ten minutes to set up and then runs on every cost change, forever.

What you will build

  1. Workflow Trigger Extensions detects the cost change and starts a Flow, carrying the variant, its SKU, and both the old and new cost.
  2. Shopify Flow passes those values into a function as JSON.
  3. Workflow Functions runs a few lines of JavaScript that calculate the new prices and write them back through the Admin API.

The pricing rule lives in one place, in code you control, so changing it later means editing one line rather than rebuilding a workflow.

Step 1: Turn on the cost trigger

In Workflow Trigger Extensions, open the Triggers page, find the Inventory group, and switch on Product Variant Cost Changed.

This trigger needs Inventory Data Access, because cost per item lives on the inventory item rather than on the variant itself. If the permission has not been granted yet, switching the trigger on will ask for it in a single dialog. Until it is granted, the trigger stays on but shows a Permission needed flag and will not fire.

What the trigger gives you

Once it fires, your Flow has access to:

Field Example
variantCostChange.variantId gid://shopify/ProductVariant/54589751394579
variantCostChange.variantTitle Medium / Black
variantCostChange.sku TSHIRT-M-BLK
variantCostChange.oldCost.amount 4.00
variantCostChange.newCost.amount 5.20
variantCostChange.newCost.currencyCode EUR

The product itself is also included as a product reference, so you can add Flow conditions on vendor, type, tags or collection before any price is touched.

Step 2: Create the function

In Workflow Functions, create a new function and paste the code below.

Before saving, switch on Needs Shopify data. That is what exposes ctx.shopify.graphql(...) to your code so it can read and modify store data. Then open the Permissions page in Workflow Functions and grant write products, which is the scope this function needs to change a price.

Without that toggle the ctx.shopify object is not available and the function fails on the first Admin API call.

update-variant-price.jsjavascript
export default async function (input, ctx) {
  const toGid = (value, type) => {
    if (value === undefined || value === null) return null;
    const str = String(value).trim();
    if (!str) return null;
    if (str.startsWith('gid://')) return str;
    if (/^\d+$/.test(str)) return `gid://shopify/${type}/${str}`;
    return str;
  };

  try {
    const variantId = toGid(input.variantId || input.variant_id || input.id, 'ProductVariant');
    if (!variantId) throw new Error('Missing "variantId" in input.');

    const hasPrice = input.price !== undefined && input.price !== null && String(input.price).trim() !== '';
    const hasCompareAt = Object.prototype.hasOwnProperty.call(input, 'compareAtPrice') || Object.prototype.hasOwnProperty.call(input, 'compare_at_price');
    const rawCompareAt = input.compareAtPrice !== undefined ? input.compareAtPrice : input.compare_at_price;

    if (!hasPrice && !hasCompareAt) throw new Error('Nothing to update: provide "price" and/or "compareAtPrice".');

    const asMoney = (value) => {
      const num = Number(value);
      if (!Number.isFinite(num) || num < 0) throw new Error(`Invalid money value: ${JSON.stringify(value)}`);
      return num.toFixed(2);
    };

    let productId = toGid(input.productId || input.product_id, 'Product');

    if (!productId) {
      const readQuery = `
        query VariantForPriceUpdate($id: ID!) {
          productVariant(id: $id) { id product { id } }
        }`;
      const readRes = await ctx.shopify.graphql(readQuery, { id: variantId });
      if (readRes.errors) throw new Error('Failed to read variant: ' + JSON.stringify(readRes.errors));
      const variant = readRes.data && readRes.data.productVariant;
      if (!variant) throw new Error(`Variant not found: ${variantId}`);
      productId = variant.product && variant.product.id;
      if (!productId) throw new Error('Could not resolve the parent product id for the variant.');
    }

    const variantInput = { id: variantId };
    if (hasPrice) variantInput.price = asMoney(input.price);
    if (hasCompareAt) {
      variantInput.compareAtPrice = rawCompareAt === null || String(rawCompareAt).trim() === '' ? null : asMoney(rawCompareAt);
    }

    ctx.log('Updating variant', variantId, 'on product', productId, variantInput);

    const mutation = `
      mutation UpdateVariantPrice($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
        productVariantsBulkUpdate(productId: $productId, variants: $variants) {
          productVariants { id }
          userErrors { field message }
        }
      }`;

    const res = await ctx.shopify.graphql(mutation, { productId, variants: [variantInput] });
    if (res.errors) throw new Error('GraphQL error updating variant: ' + JSON.stringify(res.errors));

    const payload = res.data && res.data.productVariantsBulkUpdate;
    if (!payload) throw new Error('Unexpected empty response from productVariantsBulkUpdate.');
    if (payload.userErrors && payload.userErrors.length) {
      throw new Error('Variant update failed: ' + payload.userErrors.map(e => `${(e.field || []).join('.')}: ${e.message}`).join('; '));
    }

    return { success: true };
  } catch (err) {
    ctx.log('Error updating variant price:', err.message);
    return { success: false };
  }
}

A few things worth noting about the code:

  • It accepts a bare numeric id or a full GID. toGid normalises either, so you do not have to worry about which form Flow hands you.
  • It looks up the parent product only when it has to. productVariantsBulkUpdate requires a product id; if you do not pass one, the function fetches it. Passing productId in the input skips that round trip.
  • It checks userErrors, not just transport errors. A GraphQL call can return HTTP 200 and still refuse the write. This is the most common reason a price update appears to succeed but nothing changes.
  • Clearing compare-at is deliberate. Passing compareAtPrice: null or an empty string removes it, rather than being treated as a mistake.

Step 3: Test the function before wiring it up

Use the Test tab with a real variant id from your store. Take one from any product URL in the admin, or from the trigger's event history.

Set a price you will recognise so you can confirm the write landed.

test-input.jsonjson
{
  "variantId": "gid://shopify/ProductVariant/54589751394579",
  "price": "24.99",
  "compareAtPrice": "39.99"
}

Step 4: Wire the Flow

Create a workflow in Shopify Flow:

  1. Trigger: Product Variant Cost Changed
  2. Action: Run Function from Workflow Functions, and select the function you just created

In the JSON input field, paste this. Flow replaces the Liquid with real values each time it runs.

flow-input.jsonjson
{
  "variantId": "{{variantCostChange.variantId}}",
  "price": "{{variantCostChange.newCost.amount | times: 5}}",
  "compareAtPrice": "{{variantCostChange.newCost.amount | times: 7}}"
}

That is the whole pricing rule: five times cost as the selling price, seven times cost as the compare-at price, so the product always displays a consistent discount.

Those multipliers are an example, not a recommendation. Change times: 5 to whatever your category actually supports. Liquid handles the arithmetic, so a fixed markup instead of a multiplier is plus: 12, and a percentage margin is times: 1.6.

If you sell in several currencies, note that newCost.amount is in the shop's currency. Markets handles the conversion at display time, so you do not need to.

Confirm it works end to end

  1. Open any product variant in Shopify admin and change Cost per item
  2. Save
  3. Within a few seconds, the price and compare-at price update themselves

If nothing happens, work through it in this order, because each step rules out the one before it:

Check Where
Did the trigger fire? Event History in Workflow Trigger Extensions
Did the Flow run? Shopify Flow activity log
Did the function run, and what did it log? Run history in Workflow Functions

The most common cause of a run that reports failure is a missing scope. ctx.shopify is only available when Needs Shopify data is on, and the write only succeeds once write products is granted.

Where to go next

The same pattern works for anything a cost change should touch. Swap the function body and you can tag a product for review when cost rises more than 10 percent, post the old and new margin to Slack, or write the change to a metafield for reporting.

The trigger is the part Shopify does not give you. What happens next is ordinary code.

Related articlePermissions and data accessWhat Inventory Data Access unlocks, and how to grant or revoke it.Related articleComplete trigger referenceEvery trigger in Workflow Trigger Extensions, with the fields each one carries.

Workflow Functions documentation