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 FreeSingle product, no variants? Use Product Snippet or Merchant Listing schema instead.
Product Variants in Google Shopping
What Product Variants schema unlocks
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.
| Property | Level | Status | Notes |
|---|---|---|---|
| name | Group | Required | Product family name (e.g. "Wool Winter Coat"). Must match the h1 on the page. |
| url | Group | Required | Canonical URL for the product family page. |
| variesBy | Group | Required | Dimensions 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. |
| hasVariant | Group | Required | Array 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. |
| productGroupID | Group | Recommended | Your 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. |
| brand | Group | Recommended | Brand as a nested Brand object: {"@type": "Brand", "name": "Acme"}. Required for apparel Shopping eligibility. |
| description | Group | Recommended | Product family description. Helps Google understand the product and improves Shopping matching accuracy. |
| image | Group | Recommended | Hero image for the product family. Must be crawlable and indexable. |
| aggregateRating | Group | Recommended | Group-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. |
| name | Variant | Required | Variant-specific name (e.g. "Wool Coat — Red, M"). |
| offers | Variant | Required | Offer 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 / material | Variant | Recommended | The 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. |
| url | Variant | Recommended | Canonical 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. |
| image | Variant | Recommended | Variant-specific image (e.g. the red colourway). Directly influences which image appears in Shopping for this variant. |
| sku / gtin13 | Variant | Recommended | Variant-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. |
| inProductGroupWithID | Variant | Recommended | Used 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.shippingDetails | Variant | Optional | Required 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. |
| hasMerchantReturnPolicy | Variant | Optional | Required for Merchant Listing eligibility on variant purchase pages. returnPolicyCategory and applicableCountry are required within the object. Define at Organisation level to avoid repetition. |
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.
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.
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.
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.
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.
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.
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.
{
"@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.
[
{
"@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.
{% 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.
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.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.OutOfStock availability is valid. A variant with no offers object at all is not."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.variesBy must appear as a property on every variant. Check this as part of your QA process, not as an afterthought.Frequently asked questions
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.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.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.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.