Shopify metafieldsSet: Batch Writes and compareDigest

Shopify’s metafieldsSet mutation creates or updates up to 25 metafields in one atomic call, on any mix of owners. Omit namespace and it writes to your app’s reserved namespace. The value is always a string, so lists and JSON go through JSON.stringify. To stop two writers overwriting each other, read compareDigest first and send it back with the write.

Everything below was checked against the GraphQL Admin API 2026-10 reference, the latest stable version on Shopify’s versioning page as of November 2026.

What does one metafieldsSet call actually do?

It upserts. The metafieldsSet reference says values “will be set regardless if they were previously created or not”, so there is no separate create path to maintain. The limits are 25 metafields per call and 10MB for the whole request payload.

The call is atomic. If any one of the 25 inputs fails, nothing is persisted, including the 24 that were fine. Each entry in userErrors carries an elementIndex, the position in your input array of the metafield that caused it, so you can drop or fix that entry and resend the rest.

Permissions follow the owner. Writing a metafield on a product needs the same access as editing the product, and on an order the same as editing the order.

Six fields go into each MetafieldsSetInput: ownerId, key, value, and optionally namespace, type and compareDigest. type is only required when no definition exists for that namespace, key and owner type. If you ship definitions in TOML, as in our metafield definitions post, the definition supplies the type, but sending it anyway costs nothing and makes the payload readable.

How do you write a list or JSON metafield?

Serialise it yourself. value is a String! whatever the metafield type, so a list.single_line_text_field takes JSON.stringify(["red", "blue"]), and a json metafield takes the stringified object. Reading back, jsonValue gives you the parsed form and value the raw string.

There is no append. Writing a list replaces the whole list, which is the root of the race in the next section.

The metafield limits page sets the ceilings. Lists hold 128 items, except metaobject references, which allow 1024. Most types cap a value at 64KB, json at 128KB, and every value at 2MB. The 128KB json cap has a cut-off date: apps that used JSON fields before 1 April 2026 are grandfathered, and new apps that need more have to request an exception. If your app stores a growing JSON blob per product, check which side of that date it falls on before relying on values above 128KB.

How does compareDigest prevent lost updates?

Picture two webhook handlers adding a tag to the same product’s list metafield. Both read ["a"]. One writes ["a", "b"], the other writes ["a", "c"], and b is gone. Nobody gets an error.

Since 2024-07, metafieldsSet supports compare-and-set. Query the metafield’s compareDigest, a digest of the stored value, and send it back on the write. If the stored value has changed since you read it, the mutation refuses the write and returns a user error. If the metafield does not exist yet, send compareDigest: null, which guards the create in the same way. Leave the field out entirely and you opt out of the check, as the reference’s own first example does.

This function appends one item to a list metafield on any owner and retries on a conflict:

const SHOP = process.env.SHOP_DOMAIN!;
const TOKEN = process.env.ADMIN_TOKEN!;
const URL = `https://${SHOP}/admin/api/2026-10/graphql.json`;

async function admin(query: string, variables: object) {
  const res = await fetch(URL, {
    method: 'POST',
    headers: {'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN},
    body: JSON.stringify({query, variables}),
  });
  const body = await res.json();
  if (!res.ok || body.errors) throw new Error(JSON.stringify(body.errors ?? res.status));
  return body.data;
}

const READ = `#graphql
  query ReadList($ownerId: ID!, $key: String!) {
    node(id: $ownerId) {
      ... on HasMetafields {
        metafield(key: $key) { jsonValue compareDigest }
      }
    }
  }`;

const WRITE = `#graphql
  mutation WriteList($metafields: [MetafieldsSetInput!]!) {
    metafieldsSet(metafields: $metafields) {
      metafields { compareDigest }
      userErrors { field message code elementIndex }
    }
  }`;

const CONFLICT = new Set(['STALE_OBJECT', 'INVALID_COMPARE_DIGEST']);

export async function appendToList(ownerId: string, key: string, item: string, attempts = 5) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    const data = await admin(READ, {ownerId, key});
    const current = data.node?.metafield ?? null;
    const list: string[] = current ? current.jsonValue : [];
    if (list.includes(item)) return 'unchanged';

    const result = await admin(WRITE, {
      metafields: [{
        ownerId,
        key,
        type: 'list.single_line_text_field',
        value: JSON.stringify([...list, item]),
        compareDigest: current ? current.compareDigest : null,
      }],
    });
    const errors = result.metafieldsSet.userErrors;
    if (errors.length === 0) return 'written';
    if (!errors.every((e: {code: string}) => CONFLICT.has(e.code))) {
      throw new Error(JSON.stringify(errors));
    }
  }
  throw new Error(`gave up on ${ownerId}/${key} after ${attempts} conflicts`);
}

The read uses the HasMetafields interface through node, so the same function works for a product, a variant, an order or a customer. Omitting namespace in both the query and the input targets the app-reserved namespace, which is where an app’s own data should live.

The retry set needs explaining. The MetafieldsSetUserErrorCode enum has two codes that fit: STALE_OBJECT, “The metafield has been modified since it was loaded”, and INVALID_COMPARE_DIGEST, “The compareDigest is invalid”. The reference does not say which one a digest mismatch returns. We have not run this against a live store, so the function treats both as a conflict, re-reads and tries again. Anything else, such as INVALID_VALUE, throws, because retrying a bad value only fails five times instead of once. The cap of five attempts is our choice, not a Shopify figure.

The includes check matters too. A webhook delivered twice would otherwise append the same item twice, and compare-and-set does nothing to stop that, because the second delivery reads the list after the first has written it, so its digest is current.

What does a batch of 25 cost?

It depends on how many owners the batch touches, because metafieldsSet has no fixed cost. The limits page gives the formula: 10 points, plus a weight for each distinct owner the call writes to. An Order weighs 10, a Product 4, a ProductVariant 2, and Collection, Customer, CustomerSegmentMember and Shop 1 each. Every other owner type weighs 0. An owner counts once per call however many of its metafields you set, so the most a single call can cost is 260 points, 25 metafields on 25 different orders.

That makes ordering the cheap win. Here is a chunker that keeps each owner’s metafields together and a cost function that follows the documented formula:

type Entry = {ownerId: string; key: string; type: string; value: string};

const WEIGHT: Record<string, number> = {
  Order: 10, Product: 4, ProductVariant: 2,
  Collection: 1, Customer: 1, CustomerSegmentMember: 1, Shop: 1,
};

function ownerType(gid: string) {
  return gid.split('/')[3];
}

export function cost(chunk: Entry[]) {
  const owners = new Set(chunk.map((e) => e.ownerId));
  let total = 10;
  for (const id of owners) total += WEIGHT[ownerType(id)] ?? 0;
  return total;
}

export function chunkByOwner(entries: Entry[], size = 25) {
  const byOwner = new Map<string, Entry[]>();
  for (const e of entries) {
    const list = byOwner.get(e.ownerId) ?? [];
    list.push(e);
    byOwner.set(e.ownerId, list);
  }
  const chunks: Entry[][] = [];
  let current: Entry[] = [];
  for (const group of byOwner.values()) {
    if (current.length + group.length > size && current.length > 0) {
      chunks.push(current);
      current = [];
    }
    for (const e of group) {
      if (current.length === size) {
        chunks.push(current);
        current = [];
      }
      current.push(e);
    }
  }
  if (current.length > 0) chunks.push(current);
  return chunks;
}

Take 100 metafields, four on each of 25 products. Sliced by key, each call of 25 touches 25 products and costs 110 points, 440 in total. Grouped by owner, it takes five calls instead of four (24 metafields on six products each, then the last four), costing 34 points each and 14 for the last, 150 in total. One extra round trip buys back nearly two thirds of the points. That matters most on Standard plans, where the bucket refills slowest, as our GraphQL rate limits write-up explains.

Two traps sit next to this. Aliasing several metafieldsSet mutations into one request does not help: the 10-point base is charged per alias, and owners are not deduplicated across aliases. And splitting one owner’s 30 metafields into calls of 25 and five costs 14 points each, because the owner counts again in the second call.

When should you use a bulk operation instead?

When the job is a migration rather than a sync. The bulk import guide now says bulkOperationRunMutation accepts any Admin mutation apart from the two bulk mutations themselves, which includes metafieldsSet. Each line of the JSONL file is one set of variables, the file can be up to 100MB, and the operation is not subject to the ordinary rate limits. The upload steps are the same as in our bulk operations guide.

Bulk mode and compare-and-set do not sit well together, though. Each line’s digest comes from a read made before the file was uploaded, possibly hours earlier, so on a live store any value another writer changed after your read fails its digest check, and you reconcile those lines from the result file afterwards. For a one-off backfill onto a store nobody else is writing to, leave compareDigest out. For anything that runs while merchants and other apps are active, use ordinary calls.

Deletes have their own limit. metafieldsDelete takes up to 250 identifiers per call, the platform-wide maximum for input arrays, against 25 for metafieldsSet.

What we would do

For a sync that runs alongside other writers, write in calls of 25, grouped by owner, with compareDigest on every read-modify-write and a retry on both conflict codes. For a migration into a quiet store, skip the digest and use bulkOperationRunMutation. And if a value is only ever written by one process and never merged with what is there, a plain metafieldsSet without a digest is fine.

The code above passed node:test on Node 24.12.0 against a fake Admin API we wrote, covering a race on create, a race on update with each conflict code, giving up after the cap, and non-conflict errors. Both GraphQL operations validate against the 2026-10 Admin schema. None of it has run against a real store, so which conflict code Shopify returns rests on the documentation.

Whoooop builds Shopify apps and integrations, including the syncs that keep metafields in step with an ERP or PIM. If your store’s custom data is drifting, our Shopify development team can trace where the writes collide.

Need this built properly?

Get in touch