Shopify productSet Mutation: Upsert by Custom ID

The productSet mutation in Shopify’s Admin GraphQL API writes a product’s whole state in one call. Give it an identifier and it upserts: Shopify looks for a matching product, updates it if one exists, and creates one if not. The catch is that list fields are replaced, not merged, so any entry you leave out of one is deleted.

That is the behaviour you want for a nightly sync out of an ERP or a PIM. It is also the behaviour that deletes variants you never mentioned, if you reach for the mutation expecting a patch. Everything below was written against Admin API version 2026-07, which is the latest stable version as of September 2026 (2026-10 is the release candidate).

What does productSet replace, and what does it leave alone?

The reference page splits the answer in two. For list fields it “Creates new entries, updates existing entries, and deletes existing entries that aren’t included in the mutation’s input”, and it names collections, metafields and variants as the common examples. For everything else it “Updates only the included fields. Any omitted fields will remain unchanged.”

So a product’s title survives being left out. Its variants do not. Shopify’s own sync guide annotates its example with the consequence: “If the product had 5 variants before, it will have 3 after (2 deleted).”

Variants are matched on their option values, and optionValues is a required field on every entry. Drop it and the request fails schema validation before it reaches the store:

Field "ProductVariantSetInput.optionValues" of required type "[VariantOptionValueInput!]!" was not provided.

Options and variants also travel together. Send one without the other and you get PRODUCT_OPTIONS_INPUT_MISSING (“Must specify product options when updating variants”) or VARIANTS_INPUT_MISSING (“Must specify variants when updating options”).

How do you upsert a product by custom ID?

Key the sync on your own identifier. Shopify supports that through an id-type metafield it calls a custom ID, so you do not have to store and reconcile a Shopify GID for every product. Create the definition once:

mutation CreateProductCustomIdDefinition {
  metafieldDefinitionCreate(
    definition: {
      name: "My Custom ID"
      namespace: "custom"
      key: "id"
      type: "id"
      ownerType: PRODUCT
      pin: true
    }
  ) {
    createdDefinition {
      id
      namespace
      key
    }
    userErrors {
      field
      message
      code
    }
  }
}

Do not copy that mutation name from the surrounding prose. Shopify’s custom IDs page tells you to use “the GraphQL Admin API’s createMetafieldDefinition mutation”, and a heading on the same page repeats it as CreateMetafieldDefinition. Send that to the 2026-07 schema and you get Cannot query field "createMetafieldDefinition" on type "Mutation". The page’s own code sample uses metafieldDefinitionCreate, which is the one that validates.

With the definition in place, every sync is the same call:

mutation SyncProduct {
  productSet(
    identifier: { customId: { namespace: "custom", key: "id", value: "ERP-10428" } }
    input: {
      title: "Brompton C Line Explore"
      vendor: "Brompton"
      status: ACTIVE
      metafields: [{ namespace: "custom", key: "id", type: "id", value: "ERP-10428" }]
      productOptions: [
        { name: "Colour", values: [{ name: "Racing Green" }, { name: "Black Lacquer" }] }
      ]
      variants: [
        {
          optionValues: [{ optionName: "Colour", name: "Racing Green" }]
          sku: "BR-CLE-GRN"
          price: "1795.00"
          inventoryQuantities: [
            { locationId: "gid://shopify/Location/1", name: "on_hand", quantity: 4 }
          ]
        }
        {
          optionValues: [{ optionName: "Colour", name: "Black Lacquer" }]
          sku: "BR-CLE-BLK"
          price: "1795.00"
          inventoryQuantities: [
            { locationId: "gid://shopify/Location/1", name: "on_hand", quantity: 2 }
          ]
        }
      ]
    }
  ) {
    product {
      id
      handle
    }
    userErrors {
      code
      field
      message
    }
  }
}

The metafield is repeated inside input on purpose. METAFIELD_MISMATCH says “The input argument metafields (if present) must contain the customId value”. Metafields are a list field, so a sync that sent some other metafield and left this one out would be asking Shopify to delete the key it had just been identified by.

identifier also accepts handle and id. It arrived in 2025-04, in the same release that gave customerSet the same treatment, which is handy if you are already carrying a customer list across. The changelog describes it as “a straightforward, idempotent mechanism to create and subsequently update records with the same shape of inputs, without the need for extra queries to check if records exist”.

The older route, passing id inside input, still parses. It is also deprecated, with the reason given as “Use identifier instead to get the product’s ID”, and Shopify’s schema validator flags it on sight. The sync guide’s examples have not caught up and still use it.

When should you set synchronous: false?

Default is true, and the mutation returns the product. The reference is direct about when to switch: “Setting synchronous: false may be desirable depending on the input complexity/size, and should be used if you are experiencing timeouts.” The input fields carry documented complexity costs: 0.2 per variant, 0.4 per metafield, and 1.9 per file attached to the product. A thousand-variant product is a far bigger request than a two-variant one.

There is one place the argument is ignored. Run productSet inside a bulk operation import and “the mutation will always run synchronously and this argument will be ignored”. Bulk operations are also how you read a large catalogue back out without tripping the throttle.

Asynchronously, you get an operation to poll instead:

mutation SyncProductAsync {
  productSet(
    synchronous: false
    identifier: { customId: { namespace: "custom", key: "id", value: "ERP-10428" } }
    input: { title: "Brompton C Line Explore" }
  ) {
    productSetOperation {
      id
      status
    }
    userErrors {
      code
      field
      message
    }
  }
}

query PollProductSetOperation {
  productOperation(id: "gid://shopify/ProductSetOperation/1") {
    ... on ProductSetOperation {
      id
      status
      product {
        id
        handle
      }
      userErrors {
        code
        field
        message
      }
    }
  }
}

Which statuses does the polling loop actually see?

Here the docs disagree with themselves, and the schema is the one to follow. The sync guide’s polling sample carries three comments: “Status indicates the current state: CREATED, COMPLETE, or FAILED”, “Continue polling while status is CREATED”, and “Check userErrors if status is FAILED to understand what went wrong”.

The ProductOperationStatus enum has three values, and FAILED is not one of them. They are CREATED (“Operation has been created.”), ACTIVE (“Operation is currently running.”) and COMPLETE (“Operation is complete.”). A loop written to those comments can fail two ways. It stops polling when the status turns ACTIVE and reads a product that the guide itself says “is null while the operation is processing”, or it sits waiting for a FAILED that never arrives.

Poll while the status is anything other than COMPLETE, then read userErrors, which the productOperation query documents as the place mutation errors surface:

const POLL_OPERATION = `#graphql
  query PollProductSetOperation($id: ID!) {
    productOperation(id: $id) {
      ... on ProductSetOperation {
        status
        product { id handle }
        userErrors { code field message }
      }
    }
  }`;

type AdminClient = {
  graphql: (query: string, options: { variables: Record<string, unknown> }) => Promise<Response>;
};

type SetOperation = {
  status: "CREATED" | "ACTIVE" | "COMPLETE";
  product: { id: string; handle: string } | null;
  userErrors: { code: string | null; field: string[] | null; message: string }[];
};

export async function awaitProductSet(
  admin: AdminClient,
  operationId: string,
  { intervalMs = 500, maxPolls = 60 } = {},
): Promise<{ id: string; handle: string }> {
  let status: SetOperation["status"] = "CREATED";

  for (let poll = 0; poll < maxPolls; poll++) {
    const response = await admin.graphql(POLL_OPERATION, { variables: { id: operationId } });
    const operation: SetOperation = (await response.json()).data.productOperation;
    status = operation.status;

    if (status === "COMPLETE") {
      if (operation.userErrors.length > 0) {
        const detail = operation.userErrors.map((e) => `${e.code}: ${e.message}`).join("; ");
        throw new Error(`productSet failed for ${operationId}: ${detail}`);
      }
      if (operation.product === null) {
        throw new Error(`productSet completed for ${operationId} with no product`);
      }
      return operation.product;
    }

    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`productSet ${operationId} still ${status} after ${maxPolls} polls`);
}

Which errors will a catalogue sync hit first?

ProductSetUserErrorCode is worth reading once in full, because several of its entries only make sense in a sync. ID_NOT_ALLOWED means “The id field is not allowed if identifier is provided”, so pick one addressing scheme per call. INPUT_MISMATCH fires when “The identifier value does not match the value of the corresponding field in the input”, and MISSING_FIELD_REQUIRED when “The input field corresponding to the identifier is required”. HANDLE_NOT_UNIQUE is blunter: “Handle already in use. Please provide a new handle.”

Two ceilings are worth planning around. Stores have “a limit of 2048 product variants for each product” by default, and VARIANTS_OVER_LIMIT reports “Number of product variants exceeds shop limit.” Inventory has its own: INVENTORY_QUANTITIES_LIMIT_EXCEEDED reads “Inventory quantity input exceeds the limit of 50000. Consider using separate inventorySetQuantities mutations.” The inventoryQuantities field on a variant accepts only available or on_hand, so anything more careful than a blunt overwrite belongs in a compare-and-swap inventory write rather than in productSet.

Use productSet when your external system genuinely owns the product and Shopify is the mirror. Send the complete state every time, key it on a custom ID, and keep the synchronous default until a real timeout pushes you onto the operation. If Shopify is where merchandisers edit copy, images or collections by hand, reach for productVariantsBulkUpdate or productOptionsCreate instead, because a full-state write deletes the variants, collections and metafields your input does not mention.

We build and maintain these syncs for merchants moving onto Shopify or wiring it to an existing back office. If you have a catalogue that lives somewhere else and needs to arrive intact, our ecommerce development work is the place to start.

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