GuideUpdated 2026-07-06

Products, variants and options in Shopify

How Shopify's Product → Options → Variants model maps to a Google Shopping feed, why every sellable variant becomes its own offer, and what has to be variant-level for the feed to work.

Plain-English summary

Shopify organises a catalogue as Products that hold Options (like Colour and Size) whose combinations are Variants. Google Shopping has no such nesting, it only understands flat offers, one per buyable thing. So each Shopify variant becomes its own Google offer with its own ID, price, stock and image, and the parent Product becomes the thread that groups them. Getting the feed to send variant-level data, not product-level data repeated, is what separates a working feed from a broken one.

Two structures that don't line up

Shopify models a catalogue in three layers. A Product ("Rio Dress") holds up to three Options (Colour, Size, Length). The combinations of those option values are Variants: "Red / 10", "Red / 12", "Blue / 10" and so on. Each variant is the actual sellable unit and carries its own price, SKU, barcode, weight, inventory count and image. A product can hold up to 2,048 variants and up to 100 variants per single option.

Google Shopping has none of this nesting. It understands one thing: a flat offer. An offer is one buyable item with one ID, one price, one availability state and one landing page. There is no "product with options underneath": there is only a list of offers, plus a string that says which offers belong together.

So the mapping is a flattening. Every Shopify variant becomes one Google offer. The Shopify Product doesn't map to an offer at all: it survives only as the item_group_id shared across its variant offers.

The core mapping

Shopify concept Google attribute Notes
Variant One offer Every sellable variant is its own offer row
Variant ID id The offer's unique, stable identifier
Product ID item_group_id Groups a product's variants: see variants and item_group_id
Product title + variant option values title "Rio Dress" alone can't represent Red / 10; option values need appending
Option "Colour" value color Only when the option is recognised as colour
Option "Size" value size Same: depends on the option name being read correctly
Variant price price / sale_price Per variant, not per product
Variant inventory + policy availability Per variant, so a sold-out size goes out of stock alone
Variant image, else product image image_link The variant's own image where one is set
Variant barcode gtin The variant's own: see SKUs and barcodes

The single most important line here is the first one: one variant, one offer. Everything else follows from taking that seriously.

What "variant-level" actually demands

The flattening only works if the feed reads each attribute from the variant, not from the parent product. When a feed lazily repeats product-level data across every variant, the offers become wrong in ways Google punishes:

  • Price: size-based pricing is common (a size 20 costs more than a size 8). If every variant inherits one product price, the variants that differ get price-mismatch disapprovals against their own landing pages.
  • Availability: the entire point of variant offers. The sold-out 10 goes out of stock while the 12 keeps serving. Product-level availability either wastes spend on unbuyable clicks or hides sellable stock.
  • Image: a green dress must show the green image. Repeating the hero shot across colour variants kills click-through on the colours that aren't the hero.
  • Barcode → gtin: each variant has its own barcode because GTINs exist at the sellable-unit level. Sharing one across sizes is a classic disapproval.
  • Landing page: the link should carry the ?variant= parameter so Google's crawler lands on the exact variant and sees the price and stock the feed claimed.

Options are free text: and Google isn't

Shopify option names are arbitrary per product. One product calls its colour option "Colour", another "Shade", another "Colourway". Google only has color and size. If the pipeline maps options by literal name, every product that didn't happen to type "Color" ships without a colour attribute: and loses every impression that came from a shopper filtering by colour.

The same applies to option values: "Racing Green", "Bottle", "Forest" are three greens to a shopper searching "green". Google's colour and size attributes expect normalised values, so the raw option value usually needs cleaning on the way into the feed.

Deep dive When the variant structure itself is the problem

The mapping above assumes your Shopify variant structure is correct. Often it isn't: and the feed can only be as good as the model underneath it. The recurring structural faults:

  1. A product split into one listing per colour. Instead of one "Rio Dress" with a Colour option, the store has "Rio Dress Red", "Rio Dress Blue" and "Rio Dress Green" as three separate products. Each becomes its own item_group_id, so Google never learns they're one dress: colour filtering breaks and the three compete against each other in the auction.

  2. Unrelated products merged under one product. The opposite fault: a single Shopify product using the Size option to hold genuinely different items. Google expects group members to differ only in variant attributes; unrelated members confuse serving and can suppress the whole group.

  3. Options doing double duty. A "Colour" option whose values are actually "Red", "Red (last one)", "Red: clearance". These aren't three colours; they're one colour plus inventory notes. Feed-side, they fragment the color attribute and each spawns a distinct offer.

  4. Concatenated options. Products that pack Colour × Size into a single "Style" option ("Red-Small", "Red-Medium") instead of two real options. The feed then can't split them into separate color and size attributes, and attribute-filtered impressions vanish.

None of these throw an error inside Shopify: the store operates fine. They only surface as weak Shopping performance. Because /tools reads your Shopify data and writes improvements to its own supplemental layer, it can define the correct variant relationships at the feed level (grouping split colour-listings back into one family, splitting concatenated options) without ever rewriting your Shopify products. The Variant Builder exists precisely for catalogues whose structure needs correcting before the feed can be trusted.

How /tools handles this

The Optimise sync reads Shopify variants natively: one offer per variant, id from the immutable variant ID, item_group_id from the immutable product ID, and per-variant price, stock, image and barcode read where they actually live. Option names are normalised into color and size regardless of what each product called them, and option values are cleaned toward Google's expectations. Where the underlying variant structure is broken, the Variant Builder fixes the relationships in the feed layer: your Shopify catalogue is read, never overwritten. Broken groupings surface in the Feed Health Score before Google notices them.

Frequently asked questions

Does Google want one entry per product or one per variant?

One per variant. Each colour/size combination is its own offer with its own ID, price, availability and image. The parent product only survives in the feed as the item_group_id that ties the variants together.

My product has no variants, how does it map?

A product with no options still has exactly one default variant in Shopify, and that becomes a single offer. Nothing special is needed; it just maps one-to-one.

Why does my feed show the same price for every size when they differ?

The feed is sending product-level data instead of reading each variant's own price. Variant price, stock, barcode and image all live at the variant level in Shopify, the feed has to read them there.

Put this into practice. /tools rebuilds messy product data into Merchant Center-ready feeds. Connect a store and see your Feed Health Score in minutes.
Try /tools →