A wool sweater that comes in five sizes and four colors has twenty SKUs behind it. From a buyer’s perspective it is one product. From a catalog’s perspective it is twenty inventory records. From an AI surface’s perspective — it depends entirely on how the schema is structured. The wrong structure makes the twenty SKUs invisible at retrieval time; the right structure exposes each SKU to its specific queries while keeping the buyer’s “one product” experience intact.
Variant handling is one of the higher-error parts of product
schema. Most Shopify themes ship a default pattern that collapses
variants into a single Product block. The result: a buyer query
for “wool sweater size M in navy” matches the parent product, but
the surface has no way to confirm that the specific variant is
available. The surface either omits the catalog from the
candidate set or surfaces it with low confidence.
This guide walks through the two correct Schema.org patterns, when to use each, the Shopify Liquid that produces them, and the seven failure modes that show up most often in production.
The decision tree:
If the product is not configurable (one SKU, one set of
attributes), the simple Product pattern is correct and the
rest of this guide is not relevant. The rest applies only to
configurable products.
Pattern 1 — The flat Product with one Offer
For non-configurable products. A single SKU, one set of attributes, one price, one availability.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"sku": "ACME-WS-001",
"gtin13": "0840062303849",
"name": "Acme wool runner — navy, men's, size 10",
"image": "https://yourstore.com/cdn/shop/products/wool-runner-navy.jpg",
"description": "A weatherproof merino wool sneaker...",
"brand": { "@type": "Brand", "name": "Acme Outfitters" },
"material": "Merino wool",
"color": "Navy",
"size": "10",
"offers": {
"@type": "Offer",
"price": "98.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://yourstore.com/products/wool-runner?variant=12345"
}
}
</script>
Use this pattern when there is exactly one variant — or when the product has no variants at all. Putting a configurable product into this pattern collapses its variants, which is the most common variant-handling failure.
Pattern 2 — ProductGroup with hasVariant
For configurable products: a parent ProductGroup containing
variant Products. This is the Schema.org-recommended pattern
since
2020 and is what Google’s
documentation and most AI surfaces expect.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"productGroupID": "ACME-WS",
"name": "Acme wool runner",
"image": "https://yourstore.com/cdn/shop/products/wool-runner.jpg",
"description": "A weatherproof merino wool sneaker...",
"brand": { "@type": "Brand", "name": "Acme Outfitters" },
"material": "Merino wool",
"variesBy": ["color", "size"],
"hasVariant": [
{
"@type": "Product",
"sku": "ACME-WS-NAVY-10",
"gtin13": "0840062303849",
"name": "Acme wool runner — navy, size 10",
"color": "Navy",
"size": "10",
"image": "https://yourstore.com/cdn/shop/products/wool-runner-navy.jpg",
"offers": {
"@type": "Offer",
"price": "98.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://yourstore.com/products/wool-runner?variant=12345"
}
},
{
"@type": "Product",
"sku": "ACME-WS-NAVY-11",
"gtin13": "0840062303856",
"name": "Acme wool runner — navy, size 11",
"color": "Navy",
"size": "11",
"image": "https://yourstore.com/cdn/shop/products/wool-runner-navy.jpg",
"offers": {
"@type": "Offer",
"price": "98.00",
"priceCurrency": "USD",
"availability": "https://schema.org/OutOfStock",
"url": "https://yourstore.com/products/wool-runner?variant=12346"
}
}
// ... additional variants
]
}
</script>
The structure has four important parts:
@type: "ProductGroup". The parent’s type. This signals to
the parser that the variants in hasVariant are not separate
products but variants of the same product family.
productGroupID. A stable identifier for the family. Use
the parent product’s SKU or a derived identifier. This is what
lets AI surfaces deduplicate variants across pages or sessions.
variesBy. An array naming the dimensions on which the
variants differ. Useful for the parser to know that variants
differ on color and size specifically. Common values:
color, size, material, pattern.
hasVariant. An array of Product objects, one per
variant. Each variant has its own SKU, GTIN, offer, image, and
attribute values for the variesBy dimensions.
The Shopify Liquid that produces this
The standard Shopify variant data is available in templates as
{{ product.variants }}. The Liquid that produces a
ProductGroup with full variants:
{% assign product_url = canonical_url | default: request.origin | append: product.url %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"productGroupID": {{ product.id | json }},
"name": {{ product.title | json }},
"image": {{ product.featured_image | image_url: width: 1200 | json }},
"description": {{ product.description | strip_html | truncate: 5000 | json }},
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"variesBy": [
{%- for option in product.options_with_values -%}
"{{ option.name | downcase | strip_html }}"{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"hasVariant": [
{%- for variant in product.variants -%}
{
"@type": "Product",
"sku": {{ variant.sku | json }},
{%- if variant.barcode -%}
"gtin13": {{ variant.barcode | json }},
{%- endif -%}
"name": {{ variant.title | prepend: " — " | prepend: product.title | json }},
"image": {{ variant.image | default: product.featured_image | image_url: width: 1200 | json }},
{%- for option in variant.options_with_values -%}
{%- assign option_name = option.name | downcase | strip_html -%}
"{{ option_name }}": {{ option.value | json }},
{%- endfor -%}
"offers": {
"@type": "Offer",
"price": {{ variant.price | money_without_currency | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": "{% if variant.available %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}",
"url": "{{ product_url }}?variant={{ variant.id }}",
"itemCondition": "https://schema.org/NewCondition"
}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
]
}
</script>
The pattern produces:
- A
ProductGroupparent with the shared properties - A
hasVariantarray containing each variant’sProduct - Each variant carries its own SKU, GTIN (from Shopify’s
barcodefield), per-variant attributes, and its ownOffer - Each variant URL uses Shopify’s
?variant=query parameter so a click-through lands on the right variant pre-selected
A few notes on the Liquid:
product.idis used asproductGroupID— stable across the catalog lifetime. If the catalog uses a different parent identifier (e.g., a metafield-based parent SKU), substitute that.variant.barcodeis where Shopify stores the GTIN by default. If the catalog uses a metafield for GTIN, replace with the metafield reference.product.options_with_valuesandvariant.options_with_valuesexpose the option name/value pairs. The Liquid iterates them dynamically, so a product with three options (color, size, material) produces a variant block with three matching attributes.- The
forloop.lastchecks ensure no trailing commas in the JSON. Trailing commas in JSON-LD break some validators.
The seven failure modes
The most common variant-handling errors, in rough order of frequency:
1. Variant collapse — Product instead of ProductGroup
The most common pattern. The theme emits a single Product
block representing the parent. Variants exist on the page (in
the variant picker) but not in the structured data. The result:
an AI surface ranking a query like “wool runner size 11 in
navy” matches the parent product but can’t confirm the specific
variant is in stock.
Fix: switch to ProductGroup + hasVariant. The Liquid above
is the reference.
2. Variants without per-variant offers
The ProductGroup is correct, but the variants in hasVariant
each share the parent’s offers rather than carrying their
own. Common when a developer started the migration but stopped
at the structural change. Result: AI surface can find the
variants but can’t tell which are in stock or what the
per-variant price is.
Fix: each variant in hasVariant needs its own offers block
with its own price, availability, and url.
3. Missing variesBy
ProductGroup and hasVariant are correct, but variesBy is
absent. Not strictly required, but the dimension names help the
parser understand the variant axes. Without it, the parser
infers from comparing variant properties, which is more error-
prone.
Fix: add variesBy as an array of the option names that vary
across the variants.
4. Generic variant names
Variant name properties are autogenerated to be
indistinguishable: “Acme wool runner — variant 1”, “Acme wool
runner — variant 2”. The structured data is correct but the
names are useless for retrieval.
Fix: build variant names from the option values: “Acme wool runner — navy, size 10”.
5. Wrong productGroupID
Common: productGroupID set to a transient identifier (the
Shopify variant ID, or a gid://-style internal handle) rather
than a stable family identifier. Result: re-publishing the
product changes the productGroupID and AI surfaces lose the
continuity.
Fix: use the Shopify product.id or a metafield-driven stable
identifier. Avoid Shopify’s gid:// GraphQL IDs in the
structured data.
6. Duplicate GTINs across variants
Common when a theme reuses the parent’s barcode for every
variant instead of pulling the per-variant barcode. The
Schema.org parser does not flag duplicate GTINs at the page
level, but Google Merchant
Center is
explicit that the same GTIN should not be used across distinct
products or variants — duplicate-GTIN feeds get disapproved and
the issue propagates to surfaces that consume the GMC index.
Fix: each variant pulls its own variant.barcode. Variants
without a per-variant GTIN should omit the gtin13 property
entirely rather than inherit the parent’s.
7. Variant images that don’t match the variant
The variant block references an image that doesn’t actually show that color or material. Especially common when only one “featured image” is defined and the theme reuses it across every color variant. Result: an AI surface returns a “navy” variant illustrated with a “charcoal” image, which is technically correct on the data side but confusing on the surface side.
Fix: assign per-variant images in Shopify admin, and have the
Liquid pull variant.image with the parent as fallback. Catalogs
with photography gaps may need to live with the fallback until
variant photos are produced — at least the data is correct even
if the image isn’t variant-specific.
What the surface actually does with this
The reason this matters: an AI surface ranking a constrained
query ("wool runner men's navy size 11") walks the structured
data looking for a match on all four constraints. With a flat
Product block:
- Brand match: yes (if
brandis correctly populated) - Product type match: yes (from
name,description) - Color match: maybe (if
coloris on the parent) - Size match: ❌ no (parent has no
size)
Result: the surface knows the product exists but cannot confirm the specific variant is real or available. The product is either omitted from the candidate set or surfaced with a hedge (“various sizes available”).
With ProductGroup + hasVariant:
- The surface finds the parent’s name/brand/description match
- It traverses
hasVariantlooking for a variant wherecolor == "navy"andsize == "11" - It finds the specific variant
- It reads that variant’s
offers.availabilityandoffers.url - It returns the variant by name, linking to the correct variant URL
The difference is between “we have something like that” and “yes, that specific item, available, here is the link.”
Where this gets harder
Three categories worth flagging:
Headless Shopify storefronts. Server-rendered Liquid is straightforward; React/Vue/Svelte storefronts have to produce the same JSON-LD on the server. The Storefront API exposes the variant data via GraphQL — fetch it server-side and emit JSON-LD into the rendered HTML. Don’t rely on client-side JS to inject JSON-LD; many crawlers won’t see it. See Headless Shopify and AI search.
Configurable products with many variants. A swimsuit with
3 sizes × 5 colors × 3 cup sizes has 45 variants. The full
hasVariant array is large. Most parsers handle this fine, but
page weight is a real concern — minify the JSON-LD output and
consider whether all 45 variants need full property sets or
whether some properties can be inherited from the parent.
Variants that change brand or category. Rare but real: a licensed product line where one variant is “Acme by Brand X” and another is “Acme by Brand Y.” Schema-wise, these are not really variants of the same product; they are separate products. Treat them as separate Products even if Shopify treats them as one.
Related reading
- Product schema for Shopify — the broader Schema.org reference.
- Handling product variants without confusing AI agents — the operational treatment, less about schema and more about catalog data model.
- Offer schema —
the per-variant
Offerblock in depth. - Validating structured data — verifying the output against the spec.