Product Structured Data

ProductGroup & Variants Schema Markup

Colours, sizes, materials. Groups all variants under one ProductGroup so Google can show swatches and per-variant pricing in Shopping. Layered on top of either Product Snippet or Merchant Listing markup. Worth doing properly.

Generate Product Variants Schema Free

Single product, no variants? Use Product Snippet or Merchant Listing schema instead.

Product Variants in Google Shopping

Popular products
🧥
★★★★☆
+more
🧥
★★★★★
+more
Colour swatches Variant pricing Per-variant stock Size options

What Product Variants schema unlocks

Colour swatches in Shopping
Google shows colour dots on the Shopping card, so shoppers can filter by colour before clicking through.
Variant-specific pricing
Each variant shows its own price and availability, so sold-out variants and price ranges display correctly.
Reduced duplicate markup
Shared attributes (brand, description, pattern) live on the ProductGroup — variants only specify what differs.
Apparel, footwear, furniture and more
Any product with selectable options (colour, size, material, pattern) benefits from ProductGroup schema.

Key properties for Product Variants schema

ProductGroup holds shared attributes. Each variant is a Product nested inside hasVariant — or linked back via isVariantOf in flat mode. The hasVariant array must contain at least one valid variant — each variant must have an offers object or the ProductGroup can fail validation entirely.

PropertyLevelStatusNotes
nameGroupRequiredProduct family name (e.g. "Wool Winter Coat"). Must match the h1 on the page.
urlGroupRequiredCanonical URL for the product family page.
variesByGroupRequiredDimensions that vary — must be full schema.org URIs: https://schema.org/color, https://schema.org/size, https://schema.org/material, https://schema.org/pattern, https://schema.org/suggestedAge, https://schema.org/suggestedGender. Plain text values like "Color" are valid JSON-LD but will not trigger Shopping colour swatches.
hasVariantGroupRequiredArray of Product objects, one per variant. Every variant must have an offers object — one missing offer can fail the whole group. Keep out-of-stock variants with OutOfStock availability rather than removing them.
productGroupIDGroupRecommendedYour unique identifier for this product group. Should match the item_group_id in your Google Merchant Center feed — this is how Google correlates your schema with your feed.
brandGroupRecommendedBrand as a nested Brand object: {"@type": "Brand", "name": "Acme"}. Required for apparel Shopping eligibility.
descriptionGroupRecommendedProduct family description. Helps Google understand the product and improves Shopping matching accuracy.
imageGroupRecommendedHero image for the product family. Must be crawlable and indexable.
aggregateRatingGroupRecommendedGroup-level rating — requires ratingValue and reviewCount. Place at ProductGroup level when rating applies across all variants. Enables star ratings in both organic search results and Shopping.
nameVariantRequiredVariant-specific name (e.g. "Wool Coat — Red, M").
offersVariantRequiredOffer with price (numeric, greater than zero for Merchant Listing eligibility), priceCurrency, and availability. Add offers.url pointing to the variant purchase URL — this is distinct from Product.url and is used for Merchant Listing matching.
color / size / materialVariantRecommendedThe attribute values that differentiate this variant. Every dimension declared in variesBy must appear as a property on every variant — if they don't match, swatches fail silently.
urlVariantRecommendedCanonical URL for this specific variant page. For Shopify, use the ?variant=ID URL. Note: Product.url is the canonical product URL; offers.url is the purchase URL — they are typically the same for ecommerce, but both should be set.
imageVariantRecommendedVariant-specific image (e.g. the red colourway). Directly influences which image appears in Shopping for this variant.
sku / gtin13VariantRecommendedVariant-level identifiers. GTIN must pass GS1 Luhn checksum validation. If using SKU instead of GTIN, use it as the item ID in your Merchant Center feed too. GTINs are how Google matches your schema to the Shopping knowledge graph.
inProductGroupWithIDVariantRecommendedUsed when each variant is a separate Merchant Listing page — set to the productGroupID of the parent group. This links the individual variant page back to its group for colour swatch eligibility. Different from isVariantOf, which links by @id reference in flat JSON-LD structures on the same page.
offers.shippingDetailsVariantOptionalRequired for Merchant Listing eligibility on variant purchase pages. Include handling time and transit time. Define at Organisation level if shipping policy is the same across all products.
hasMerchantReturnPolicyVariantOptionalRequired for Merchant Listing eligibility on variant purchase pages. returnPolicyCategory and applicableCountry are required within the object. Define at Organisation level to avoid repetition.
Apparel and footwear: For apparel products in Google Shopping, color, size, and brand are required at the variant level. suggestedGender is strongly recommended. These requirements apply to the UK, US, Brazil, France, Germany, and Japan markets.
ProductGroup page vs. Merchant Listing variant pages: These are two different scenarios. ProductGroup on a product family page uses hasVariant to group variants for swatch eligibility. Individual variant as a separate purchase page needs full Merchant Listing markup (price, shipping, returns) plus inProductGroupWithID to link back to the group. Many sites need both: ProductGroup on the product page, Merchant Listing on each variant URL.

How to implement ProductVariant schema

Google's official documentation explains what ProductVariant schema is well enough. What it skips is the CMS that doesn't store things the way schema.org expects, the variant with a missing price that breaks the entire group, and the part where you validate it three months later and find it was never rendering correctly. Here's how to do it properly from the start.

1

Mark up the ProductGroup first

The ProductGroup holds everything your variants share: name, description, brand, hero image, aggregate rating. The most important property here is variesBy — it tells Google which dimensions differ across variants.

Use the full schema.org URI, not plain text. "https://schema.org/color" works. "Color" does not trigger Shopping swatches.

2

Choose your structure: nested or flat

Nested: all variant Products live inside hasVariant in a single JSON-LD block. Recommended for most implementations.

Flat: ProductGroup and each variant are separate JSON-LD objects. Each variant links back via isVariantOf with a shared @id. Better for headless setups or very large catalogues where variants render independently. Both are valid.

3

Add variant-level detail, including GTINs

Each variant needs at minimum: name and offers (price + currency + availability). Also add sku, gtin13, the differentiating attributes (color/size), a variant-specific URL, and a variant image where available.

Get the GTIN if you can; it's how Google matches your schema to its product database for Shopping. If you're using SKU instead, use it as the item ID in your Merchant Center feed too, so the two stay in sync.

4

Validate before you deploy

Run every implementation through Google's Rich Results Test. Common errors: missing offers on a variant, incorrect variesBy values (text vs URI), and variant URLs that don't resolve. Fix those before you move on.

5

Monitor in Search Console

After deployment, check two places: Enhancements → Merchant listings (Shopping eligibility and errors — this is where variant/swatch issues appear) and Enhancements → Product snippets (organic rich results — star ratings, review counts). They are different reports for different features. Also filter Performance → Search type: Shopping to see whether variant impressions are growing. Expect indexing signals within a few weeks. Set a reminder — variant schema problems fail silently and often go unnoticed for months.

JSON-LD examples

Nested structure (recommended)

All variants inside one hasVariant array. Simplest to implement, easiest to validate. Both Product.url and offers.url point to the variant URL — Product.url is the canonical page URL; offers.url is the purchase URL used for Merchant Listing matching. For ecommerce these are the same, but both should be set.

JSON-LD
{
  "@context": "https://schema.org/",
  "@type": "ProductGroup",
  "name": "Wool Winter Coat",
  "description": "A classic wool coat available in multiple colours and sizes.",
  "url": "https://example.com/products/wool-winter-coat",
  "brand": { "@type": "Brand", "name": "Example Brand" },
  "productGroupID": "WC-001",
  "variesBy": [
    "https://schema.org/color",
    "https://schema.org/size"
  ],
  "image": "https://example.com/images/wool-coat-hero.jpg",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": 4.7,
    "reviewCount": 89
  },
  "hasVariant": [
    {
      "@type": "Product",
      "name": "Wool Winter Coat — Camel, S",
      "color": "Camel",
      "size": "S",
      "sku": "WC-001-CAM-S",
      "gtin13": "5901234123457",
      "url": "https://example.com/products/wool-winter-coat?variant=camel-s",
      "image": "https://example.com/images/wool-coat-camel.jpg",
      "offers": {
        "@type": "Offer",
        "price": 189.00,
        "priceCurrency": "GBP",
        "availability": "https://schema.org/InStock",
        "url": "https://example.com/products/wool-winter-coat?variant=camel-s"
      }
    },
    {
      "@type": "Product",
      "name": "Wool Winter Coat — Black, M",
      "color": "Black",
      "size": "M",
      "sku": "WC-001-BLK-M",
      "gtin13": "5901234123464",
      "url": "https://example.com/products/wool-winter-coat?variant=black-m",
      "image": "https://example.com/images/wool-coat-black.jpg",
      "offers": {
        "@type": "Offer",
        "price": 189.00,
        "priceCurrency": "GBP",
        "availability": "https://schema.org/OutOfStock",
        "url": "https://example.com/products/wool-winter-coat?variant=black-m"
      }
    }
  ]
}

Flat structure (isVariantOf)

ProductGroup and each variant are separate JSON-LD objects on the same page. Variants link back via isVariantOf referencing the ProductGroup's @id. Better for headless setups or large catalogues where variants render independently. Note: if each variant has its own separate URL (its own page), use inProductGroupWithID on each variant's Merchant Listing markup instead.

JSON-LD
[
  {
    "@context": "https://schema.org/",
    "@type": "ProductGroup",
    "@id": "https://example.com/products/wool-winter-coat#product-group",
    "name": "Wool Winter Coat",
    "url": "https://example.com/products/wool-winter-coat",
    "brand": { "@type": "Brand", "name": "Example Brand" },
    "productGroupID": "WC-001",
    "variesBy": [
      "https://schema.org/color",
      "https://schema.org/size"
    ]
  },
  {
    "@context": "https://schema.org/",
    "@type": "Product",
    "name": "Wool Winter Coat — Camel, S",
    "color": "Camel",
    "size": "S",
    "sku": "WC-001-CAM-S",
    "gtin13": "5901234123457",
    "isVariantOf": {
      "@id": "https://example.com/products/wool-winter-coat#product-group"
    },
    "offers": {
      "@type": "Offer",
      "price": 189.00,
      "priceCurrency": "GBP",
      "availability": "https://schema.org/InStock",
      "url": "https://example.com/products/wool-winter-coat?variant=camel-s"
    }
  },
  {
    "@context": "https://schema.org/",
    "@type": "Product",
    "name": "Wool Winter Coat — Black, M",
    "color": "Black",
    "size": "M",
    "sku": "WC-001-BLK-M",
    "gtin13": "5901234123464",
    "isVariantOf": {
      "@id": "https://example.com/products/wool-winter-coat#product-group"
    },
    "offers": {
      "@type": "Offer",
      "price": 189.00,
      "priceCurrency": "GBP",
      "availability": "https://schema.org/InStock",
      "url": "https://example.com/products/wool-winter-coat?variant=black-m"
    }
  }
]

Shopify: product.liquid

Generates ProductGroup schema dynamically from Shopify's native variant data. Add to your product.liquid template inside a {% if product.variants.size > 1 %} check. Include variant.barcode if you have GTINs stored in Shopify. Requires Shopify Online Store 2.0 — uses the image_url filter. For legacy themes using Dawn 1.x or older, replace image_url: width: 800 with img_url: '800x'. Note: if your product options don't include Color, Size, or Material (e.g. "Style", "Scent", "Finish"), the variesBy array will output empty — add a case for your custom options or the ProductGroup won't be eligible for Shopping swatches.

Liquid
{% if product.variants.size > 1 %}
<script type="application/ld+json">
{
  "@context": "https://schema.org/",
  "@type": "ProductGroup",
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | truncatewords: 50 | json }},
  "url": {{ canonical_url | json }},
  "brand": {
    "@type": "Brand",
    "name": {{ product.vendor | json }}
  },
  "productGroupID": {{ product.id | json }},
  "image": {{ product.featured_image | image_url: width: 1200 | prepend: "https:" | json }},
  "variesBy": [
    {% assign first = true %}
    {% for option in product.options %}
      {% case option %}
        {% when "Color", "Colour" %}
          {% unless first %},{% endunless %}"https://schema.org/color"
          {% assign first = false %}
        {% when "Size" %}
          {% unless first %},{% endunless %}"https://schema.org/size"
          {% assign first = false %}
        {% when "Material" %}
          {% unless first %},{% endunless %}"https://schema.org/material"
          {% assign first = false %}
      {% endcase %}
    {% endfor %}
  ],
  "hasVariant": [
    {% for variant in product.variants %}
    {
      "@type": "Product",
      "name": {{ product.title | append: " — " | append: variant.title | json }},
      "sku": {{ variant.sku | json }},
      "url": {{ canonical_url | append: "?variant=" | append: variant.id | json }},
      "image": {{ variant.image.src | default: product.featured_image.src | image_url: width: 800 | prepend: "https:" | json }},
      {% for i in (0..2) %}
        {% assign opt = product.options[i] %}
        {% case opt %}
          {% when "Color", "Colour" %}"color": {{ variant.options[i] | json }},
          {% when "Size" %}"size": {{ variant.options[i] | json }},
          {% when "Material" %}"material": {{ variant.options[i] | json }},
        {% endcase %}
      {% endfor %}
      "offers": {
        "@type": "Offer",
        "price": {{ variant.price | divided_by: 100.0 | json }},
        "priceCurrency": {{ cart.currency.iso_code | json }},
        "availability": "https://schema.org/{% if variant.available %}InStock{% else %}OutOfStock{% endif %}",
        "url": {{ canonical_url | append: "?variant=" | append: variant.id | json }}
      }
    }{% unless forloop.last %},{% endunless %}
    {% endfor %}
  ]
}
</script>
{% endif %}

Common implementation challenges

ProductVariant schema is technically optional the same way that QA testing is technically optional. What follows is a list of things that will happen to you if you skip the hard parts.

Your CMS doesn't store variesBy as schema.org URIs
Most platforms store variant options as plain text — Shopify uses option1, option2, WooCommerce uses attribute slugs. None of them produce https://schema.org/color automatically. Sending "Color" is technically valid schema — it just won't trigger Shopping swatches.
Fix: Build a mapping layer in your template that converts platform option names to schema.org URIs. See the Shopify Liquid example above for exactly how.
One incomplete variant breaks the whole group
If any variant in your hasVariant array is missing a required property — usually offers — the ProductGroup can fail validation entirely. Discontinued variants, draft products, and seasonal items are the usual culprits.
Fix: Filter out variants with incomplete data before generating schema. A sold-out variant with OutOfStock availability is valid. A variant with no offers object at all is not.
Hardcoded schema goes stale
Prices change. Variants sell out. New colours launch. Hardcoded JSON-LD was accurate on day one and wrong by week two. Stale schema is worse than no schema — Google can surface incorrect pricing from your markup in Shopping results. That is the kind of thing that ends up as a screenshot in your inbox.
Fix: Generate schema dynamically at render time. Pull live variant data from your platform's template variables or API, not from a hardcoded block.
variesBy doesn't match your actual variant properties
If you declare "variesBy": ["https://schema.org/color"] but none of your variant objects have a "color" property, Google can't parse the variant relationships correctly. This fails silently — no validator error, no swatches.
Fix: Every dimension in variesBy must appear as a property on every variant. Check this as part of your QA process, not as an afterthought.
URL strategy decided after implementation
ProductGroup schema works differently depending on whether variants have their own URLs or share a parent URL. Mixing strategies — or switching strategy after go-live — means restructuring all your markup.
Fix: Decide your URL strategy before you write a single line of schema. Document it so the next person doesn't undo it quietly six months later.

Frequently asked questions

No. ProductGroup schema supports both a single page with all variants selectable and separate pages per variant. For separate pages, each variant Product should have its own url property. For a single page, the ProductGroup and all variants can share the same URL.
Google officially supports these full schema.org URIs: https://schema.org/color, https://schema.org/size, https://schema.org/suggestedAge, https://schema.org/suggestedGender, https://schema.org/material, and https://schema.org/pattern. These must be full URIs — plain text values like "Color" are valid JSON-LD but will not trigger Shopping colour swatches.
In nested structure, variant Products sit inside ProductGroup via hasVariant. In flat structure, the ProductGroup and each variant Product are separate JSON-LD objects, with each variant linking back via isVariantOf. Both are valid — our generator supports both with a toggle.
Yes. Add ProductGroup JSON-LD to your product.liquid template. Each Shopify variant maps to a Product object inside hasVariant. Use the variant URL (with ?variant= parameter) as the url for each variant, and the main product URL for the ProductGroup.
Google recommends placing aggregateRating at the ProductGroup level when the rating applies to the product as a whole. Only add variant-level ratings if individual variants have genuinely distinct review sets, which is rare.
Keep it in. Remove it and your schema goes stale the moment stock levels change. Set availability to https://schema.org/OutOfStock and Google will display the correct status in Shopping results — which is genuinely useful to a shopper deciding whether to click. Dynamic schema generation handles this automatically.
Yes. ProductGroup schema on your website is one of the signals Google uses for automatic item updates and feed supplementation in Merchant Center. SKUs and GTINs at the variant level are particularly important — they allow Google to match your schema data to your feed and correct pricing discrepancies automatically. If you run a Merchant Center feed alongside ProductGroup schema, GTINs are worth the effort.
The markup will be technically valid but Google can't parse the variant relationships correctly — and no swatches will appear. If you declare variesBy: [color] but your variant objects have no color property, the mismatch fails silently. Every dimension listed in variesBy must be present as a property on every variant.
inProductGroupWithID is used on individual variant pages that have their own URL — for example /trainers/red-size-10/ as a distinct product page with its own Merchant Listing schema. Set it to the productGroupID of the parent ProductGroup to link them. This is different from isVariantOf, which links by @id reference within a flat JSON-LD structure on a single page.
Yes — sale pricing is set at the variant's offers level, so individual variants can have different sale states. Use priceSpecification with a UnitPriceSpecification of type https://schema.org/StrikethroughPrice for the original price and https://schema.org/SalePrice for the discounted price. The main price field must equal the active (sale) price. See the Merchant Listing guide for a full sale pricing example.

Also in this series

Generate valid Product Variants schema

Add variants, choose nested or flat output, and get clean JSON-LD in seconds. No account needed.