How do I assign multiple images to a variant with the Shopify GraphQL API?

How do I assign multiple images to a variant with the Shopify GraphQL API?

Short answer: you need the MediaImage GID, not the ProductImage ID, and productVariantAppendMedia still stores one featured media per variant. For a real per-variant gallery, Rubik Variant Images, built by Craftshift, assigns a separate image set to every variant through metafields. Free to install, 5.0 stars across 410 reviews.

You have an image ID. You hand it to productVariantAppendMedia. Shopify hands back an error saying the media does not exist on the given product. Your token is fine. Your scopes are fine. The product ID is fine.

You are holding the wrong species of ID.

Shopify shipped two parallel ways of talking about the same JPEG, and for a few years both worked. The old one is the REST product image, with a numeric id, a src, a position and a variant_ids array. The new one is media: an image is a MediaImage file that lives in Files, gets attached to a product, and is addressed by a GID that looks like gid://shopify/MediaImage/25823372804185. The GraphQL Admin API only speaks the second language. Feed it the first and it will politely tell you nothing exists.

We build a variant image app, so we live in this corner of the API. Everything below was checked against the 2026-07 version of the GraphQL Admin API (the version the latest docs alias points at while this was written). Where the docs do not say something, this post says the docs do not say it, rather than guessing.

In this guide

Why your ProductImage ID gets rejected

Because productVariantAppendMedia takes media IDs, and a REST product image ID is not a media ID. The mutation’s input object, ProductVariantAppendMediaInput, has exactly two fields: variantId: ID! and mediaIds: [ID!]!. Those media IDs must be MediaImage GIDs that are already attached to the product named in productId. Anything else fails validation.

The REST side is worth understanding, because it is where the mental model came from. On the REST Product Image resource, the association ran the other way around: the image carried a variant_ids array, described in Shopify’s own docs as “An array of variant ids associated with the image”. One image, many variants. Never one variant, many images. People remember that array and reasonably assume the reverse exists. It does not, and it never did.

Both of those REST fields are now marked deprecated, and the resource itself carries this notice: “Listing, creating, updating, and deleting product images is deprecated as of REST API 2025-01.” Zoom out one level and the whole surface is on the way out. Shopify’s REST Admin API landing page says “The REST Admin API is a legacy API as of October 1, 2024. Starting April 1, 2025, all new public apps must be built exclusively with the GraphQL Admin API.”

So the answer to “how do I convert my ProductImage ID” is mostly: stop producing ProductImage IDs. Read media, not images, from the start. If you are auditing what your current setup even looks like, our guide to Shopify variant ID matching covers how the different ID types line up across exports, feeds and the API.

How to get the MediaImage GID

Query the product’s media connection and use an inline fragment to pull the image fields. Media is an interface implemented by MediaImage, Video, Model3d and ExternalVideo, so you have to narrow it to get at image.

query ProductMedia($id: ID!) {
  product(id: $id) {
    id
    title
    media(first: 250, sortKey: POSITION) {
      nodes {
        id
        status
        mediaContentType
        ... on MediaImage {
          alt
          mimeType
          image {
            url
            width
            height
          }
        }
      }
    }
  }
}

Every node’s id is the GID you want. The media connection accepts first, last, after, before, reverse, a sortKey of type ProductMediaSortKeys (default POSITION), and a query argument that filters on id and media_type. If you only want stills, filter with query: "media_type:IMAGE" and stop worrying about the fragment matching nothing.

Two traps live in that response. First, MediaImage.image returns null until the file’s status is READY. Files move through UPLOADED, then PROCESSING, then READY, and Shopify tells you plainly to poll until READY before associating anything. Attach too early and you get an error rather than a race you can ignore. Second, Product.images and Product.featuredImage still exist and are both marked deprecated. They will happily return data and quietly keep you in the old model.

What if you genuinely have a stored REST image ID and a database full of them? Match on the CDN URL. Read image { url } for every media node, compare it against the src you saved, and store the MediaImage GID from then on. It is unglamorous and it works. Do it once as a backfill rather than on every request, because that media query is not free against your rate limit.

The productVariantAppendMedia mutation

The signature is productVariantAppendMedia(productId: ID!, variantMedia: [ProductVariantAppendMediaInput!]!). It needs the write_products access scope, and the docs add that the user must also have permission to append media to variants. It returns product, productVariants and userErrors, where the errors are of type MediaUserError.

Ask for the error code, not just the message. MediaUserError exposes code, field and message, and the code is the only part stable enough to branch on.

mutation AppendVariantMedia($productId: ID!, $variantMedia: [ProductVariantAppendMediaInput!]!) {
  productVariantAppendMedia(productId: $productId, variantMedia: $variantMedia) {
    product {
      id
    }
    productVariants {
      id
      media(first: 10) {
        nodes {
          id
          mediaContentType
        }
      }
    }
    userErrors {
      code
      field
      message
    }
  }
}

And the variables, using the shape from Shopify’s own example:

{
  "productId": "gid://shopify/Product/1072481072",
  "variantMedia": [
    {
      "variantId": "gid://shopify/ProductVariant/1070325119",
      "mediaIds": ["gid://shopify/MediaImage/1072273216"]
    },
    {
      "variantId": "gid://shopify/ProductVariant/1070325120",
      "mediaIds": ["gid://shopify/MediaImage/1072273217"]
    }
  ]
}

A clean run returns the product and the updated variants with an empty userErrors array. Note what it does not return: any confirmation of ordering, position, or which image is now “the” image for that variant. There is nothing to order. Which brings us to the part that surprises people.

Every error code, and what each one means

These are the values of the MediaUserErrorCode enum that you will realistically hit on this mutation, with Shopify’s documented description quoted verbatim. Read the third row twice.

CodeDocumented descriptionWhat it usually means in practice
MEDIA_DOES_NOT_EXIST“Media does not exist.”You passed a ProductImage ID, a file ID from another shop, or a typo
MEDIA_DOES_NOT_EXIST_ON_PRODUCT“Media does not exist on the given product.”Valid MediaImage, but it is not attached to this product yet. Attach it to the product first
TOO_MANY_MEDIA_PER_INPUT_PAIR“Only one mediaId is allowed per variant-media input pair.”You put more than one GID in mediaIds. This is the whole ballgame, see below
PRODUCT_VARIANT_ALREADY_HAS_MEDIA“Product variant already has attached media.”That variant is taken. Detach first, or use an update mutation
NON_READY_MEDIA“Non-ready media are not supported.”You did not poll for READY. Classic on freshly uploaded files
PRODUCT_VARIANT_DOES_NOT_EXIST_ON_PRODUCT“Variant does not exist on the given product.”Variant and product IDs are from different products
PRODUCT_VARIANT_SPECIFIED_MULTIPLE_TIMES“Variant specified in more than one pair.”Your loop emitted the same variant twice. Deduplicate before sending
MAXIMUM_VARIANT_MEDIA_PAIRS_EXCEEDED“Exceeded the maximum number of 100 variant-media pairs per mutation call.”Batch in chunks of 100
MEDIA_CANNOT_BE_MODIFIED“Media cannot be modified. It is currently being modified by another operation.”Concurrency. Retry with backoff
PRODUCT_MEDIA_LIMIT_EXCEEDED“Exceeded the limit of media per product.”You are at Shopify’s ceiling for media on one product
MEDIA_IS_NOT_ATTACHED_TO_VARIANT“The specified media is not attached to the specified variant.”Detach only. You will not see this on append

Why does the API define a hundred-pair batch limit and then also refuse more than one media per pair? Honestly, it reads like two design eras colliding, and the enum is where the seam shows.

The hard boundary: one media per variant

This is the part that sends developers round in circles for a day, so here it is stated plainly. The mutation’s input field is called mediaIds and its type is [ID!]!, a non-null list of non-null IDs. The shape of the schema says list. The behaviour does not.

Four independent pieces of Shopify’s own documentation agree on this:

  1. TOO_MANY_MEDIA_PER_INPUT_PAIR exists, and its description is “Only one mediaId is allowed per variant-media input pair.”
  2. PRODUCT_VARIANT_ALREADY_HAS_MEDIA exists, and its description is “Product variant already has attached media.” A second append is not an addition, it is a collision.
  3. The bulk input object, ProductVariantsBulkInput, exposes mediaId in the singular, documented as “The ID of the media that’s associated with the variant.”
  4. On the storefront, Liquid gives you variant.featured_media, documented as “The first media object attached to the variant”, plus variant.image and variant.featured_image, which the docs state are the same single value.

There is no Liquid object that returns a set of images belonging to one variant. None. So even if you found a way to stuff five media IDs onto a variant through the Admin API, the theme has no supported way to render them, because the storefront data model exposes one featured media and nothing else.

That is the real answer to “how do I assign multiple images to a variant”. You cannot, not through the product model. Shopify’s variant-to-media relationship is one to one by design, and no amount of clever mutation work moves it. We have watched teams spend a week on this. Some of them were us.

Which is precisely why the workaround that actually ships is to store the per-variant image sets somewhere the storefront can read them, and then filter the product gallery in the browser on variant change. That is what our own app does: store a whole image set per variant in metafields and filter the product gallery when a shopper picks an option, with no external API call at render time. Free to install, 5.0 stars across 410 reviews. If you would rather build it yourself, that is a legitimate choice too, and our write-up on doing variant images without an app is the honest version of what that costs.

productVariantsBulkUpdate, the shorter path

If all you want is to set the one featured media per variant, and especially if some variants already have media attached, skip append entirely. productVariantsBulkUpdate takes ProductVariantsBulkInput, which carries mediaId, and it overwrites rather than colliding.

mutation AddMediaToVariants {
  productVariantsBulkUpdate(
    productId: "gid://shopify/Product/7882412064857",
    variants: [
      {
        id: "gid://shopify/ProductVariant/43729076682841",
        mediaId: "gid://shopify/MediaImage/25823372804185"
      },
      {
        id: "gid://shopify/ProductVariant/43729076715609",
        mediaId: "gid://shopify/MediaImage/25823372837012"
      }
    ]
  ) {
    productVariants {
      id
      media(first: 10) {
        nodes {
          alt
          mediaContentType
          ... on MediaImage {
            image {
              url
            }
          }
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

That is Shopify’s documented example, near enough verbatim. The same input also accepts mediaSrc if you would rather hand it a URL than pre-create the file. For a catalogue-sized job you are going to want this one, plus a queue and a retry policy, because a thousand products is ten thousand variants and rate limits are real. We wrote up the operational side of that in automating Shopify variant images, and matching product images to variants automatically covers the filename and ordering heuristics that decide which image belongs to which option value.

Removing media from a variant

The mirror mutation is productVariantDetachMedia(productId: ID!, variantMedia: [ProductVariantDetachMediaInput!]!). Same shape, same MediaUserError return, and it hands back product and productVariants. Detach does not delete the file, it only breaks the variant association. To actually remove the image, that is fileDelete, and it takes MediaImage GIDs too.

The detach and re-append dance is how a lot of integrations “change” a variant image. It works, but it is two round trips and a window where the variant has no image at all. productVariantsBulkUpdate with mediaId is one call and no window. Use that.

Three approaches exist, and only one of them survives a theme update.

  • Naming and ordering conventions in the theme. Group images by filename prefix or alt text, then filter the gallery in Liquid and JavaScript. Free. Breaks the moment someone uploads an image named the wrong way, and breaks again on the next theme release.
  • Product metafields you write yourself. Store a JSON map of option value to media GIDs, read it in the theme, filter on variant change. This is the correct architecture. It is also a build, plus an admin UI so a merchandiser can maintain it, plus a bulk tool so the first 800 products do not take a fortnight.
  • An app that already did the second one. Which is what we built.

Rubik Variant Images stores the variant-to-media mapping in metafields, so the storefront reads it with the page and makes no external API call to render. It handles images, videos and 3D models per variant, supports multiple option axes at once (color and material, say), and can mark shared media that should stay visible on every variant, which is how size charts survive filtering. Assignment is manual drag and drop, image-order based bulk assign across hundreds of products, or per-product AI auto-assign. Free plan covers one product; paid plans are flat at $25, $50 and $75 a month for 100, 1,000 and unlimited products.

Rubik Variant Images assigning separate image sets to multiple product options

“Very useful app for me, the standard shopify solution shows all variant images at once which may confuse buyers. With this app I can show the relevant images for the specific variant only. Besides that, I needed support for the configuration and I got very quick response from Umid who also solved my config problem very fast. Thanks.”

Sun Audio, Spain, May 2025, Rubik Variant Images on the Shopify App Store

You can poke at the storefront behaviour before installing anything: the live demo store runs it, the bulk assign walkthrough shows the admin side, and the getting started guide has the setup steps. If your problem is the opposite one, separate products that should behave like one listing rather than one product with many images, that is a different data model again and combined listings is the thing to read. There is also a deeper treatment of how many images per variant Shopify can actually handle, including Shopify’s ceiling of 250 images per product.

Planning a catalogue restructure rather than an integration? Run the numbers first with the free variant combination calculator, and if the images are arriving as a bulk import, the product CSV validator will catch the malformed rows before Shopify does.

What we could not verify

Two things, and we would rather say so than invent an answer.

First, there is no documented arithmetic that converts a legacy REST product image ID into a MediaImage GID. We looked. The reference pages define both, and nothing states a relationship between the numeric parts. So do not swap the prefix and hope. Match on the CDN URL, as described above, and treat the MediaImage GID as the only ID worth storing going forward.

Second, the exact deprecation reason string attached to ProductVariant.image is not reproduced here. The field is marked deprecated in the current schema and ProductVariant.media is the live one; we are not going to quote a replacement message we did not read verbatim. Check it in the schema explorer for the API version you are pinned to, because deprecation notes do change between versions.

FAQ

Can productVariantAppendMedia attach more than one image to a variant?

No. The input field mediaIds is typed as a list, but the documented error TOO_MANY_MEDIA_PER_INPUT_PAIR reads “Only one mediaId is allowed per variant-media input pair”, and PRODUCT_VARIANT_ALREADY_HAS_MEDIA blocks a second append. Shopify’s variant-to-media relationship is one to one.

What is the best way to show multiple images per variant on Shopify?

Store the per-variant image sets in metafields and filter the product gallery on the storefront when the shopper changes option. Rubik Variant Images, built by Craftshift, does exactly that: an image set per variant, images plus videos and 3D models, shared media that stays visible across all variants, and no external API call at render time. Free to install, 5.0 stars across 410 reviews. Building it yourself is viable; budget for the admin UI and the bulk assignment tooling, not just the theme code.

How do I convert a ProductImage ID to a MediaImage ID?

There is no documented conversion. Query the product’s media connection, read image { url } inside an ... on MediaImage fragment, and match it against the src you already stored. Then keep the GID and stop storing the old ID.

Why does the mutation return MEDIA_DOES_NOT_EXIST_ON_PRODUCT when the file clearly exists?

Because a file existing in Files is not the same as it being attached to that product. Media has to be associated with the product before a variant on that product can reference it. Attach it to the product first, wait for READY, then append to the variant.

Is the REST product image endpoint still usable?

Shopify’s docs say “Listing, creating, updating, and deleting product images is deprecated as of REST API 2025-01”, and the REST Admin API as a whole has been legacy since October 1, 2024, with new public apps required to be GraphQL-only from April 1, 2025. Existing private integrations may still work, but nothing new should be built on it.

How many variant-media pairs can I send in one call?

One hundred. The error code MAXIMUM_VARIANT_MEDIA_PAIRS_EXCEEDED is documented as “Exceeded the maximum number of 100 variant-media pairs per mutation call.” Chunk your batches and handle partial failures per pair, because one bad pair does not necessarily invalidate the rest of your logic.

Does the theme need code changes to show a filtered gallery?

If you build it yourself, yes, and you own that code through every theme update. Rubik Variant Images ships theme integrations instead and renders inside a shadow root so the styling does not collide with the theme’s CSS. Either way, the storefront filtering happens in the browser, because Liquid only exposes one featured media per variant.

The API is not broken and you are not misreading it. Shopify decided a variant has one image, wrote a list-shaped input around that decision, and left the contradiction sitting in an error enum where nobody looks. Now you know where to look.

Co-Founder at Craftshift