Structured data mastery

Variant handling in product schema

The Schema.org variant model is two patterns, not one. Choosing the wrong pattern is why most Shopify catalogs surface only the parent product to AI agents — and why size, color, and material queries return generic matches instead of the right SKU.

12 min read Updated May 10, 2026

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:

No

Yes

No

Yes

Configurable product?

Single Product
+ single Offer

Variants differ on
price or identifier?

ProductGroup
with hasVariant

ProductGroup
with hasVariant
and per-variant offers

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.

Anatomy of a ProductGroup with hasVariantA schematic showing the parent ProductGroup at the top with its shared properties (name, image, brand, description, variesBy), and three child Product variants beneath it, each with their distinguishing attributes (size, color), their own SKU, GTIN, offer, and url. Lines connect each variant to the parent’s hasVariant array.PRODUCTGROUP STRUCTURE@type: “ProductGroup”productGroupID: “ACME-WS”name, image, brand, descriptionvariesBy: [“color”, “size”]Variant 1 — Navy, size 10sku: “ACME-WS-NAVY-10”gtin13: “0840062303849”offers.price: “98.00”availability: InStockurl: ?variant=12345Variant 2 — Navy, size 11sku: “ACME-WS-NAVY-11”gtin13: “0840062303856”offers.price: “98.00”availability: OutOfStockurl: ?variant=12346Variant 3 — Charcoal, 10sku: “ACME-WS-CHAR-10”gtin13: “0840062303870”offers.price: “98.00”availability: InStockurl: ?variant=12347

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 few notes on the Liquid:

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:

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 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.