For everyone who's wrapped a single-SKU candle in ProductGroup and wondered why nothing happened.
Product and ProductGroup are not interchangeable. One describes a thing you sell. The other groups variations of that thing. The distinction matters because Google uses them at different stages, for different features, and getting the choice wrong doesn't produce an error — it produces silence.
I see this confusion constantly when auditing e-commerce structured data. A site has a product page for a running shoe that comes in six colours and four sizes. The developer marks it up as a single Product with one offer. Technically valid. Passes the Rich Results Test. But Google has no idea those six colours exist, which means no colour swatches in Shopping, no variant-level pricing, and no way for Google to match the right variant to the right query.
Alternatively, someone reads about ProductGroup and wraps every product on the site in it — even products with no variants at all. Also technically valid. Also a waste of complexity that Google doesn't reward.
The answer is simpler than the confusion suggests, and it comes down to one question: does this product have variants that a shopper would choose between?
| Product | ProductGroup | |
|---|---|---|
| What it is | A single, purchasable item — one SKU, one price | A parent container grouping variations of the same item |
| When to use | Every product page, always | Only when the product has selectable variants (colour, size, material) |
| Carries an Offer? | Yes — price, currency, availability | No — each variant Product inside it carries the Offer |
| Key properties | sku, gtin, offers, image |
variesBy, hasVariant, productGroupID |
| Google features | Star ratings, price in SERPs, Shopping listing | Colour swatches, variant-level pricing, unified product identity |
| Can stand alone? | Yes | No — always contains Product entities |
Product: the thing itself
Product is the base type. It describes a single, purchasable item. One SKU, one price, one set of attributes. If someone can add it to their cart and check out, that's a Product.
Every product page on an e-commerce site needs Product markup. There is no scenario where ProductGroup replaces it. Even when you use ProductGroup, the individual variants inside it are still typed as Product.
ProductGroup wraps Product — it does not replace it
What Product markup unlocks depends on which properties you include. Google distinguishes between two experiences here, and the distinction is important because they serve different page types:
Product Snippet — available to any page that describes a product: reviews, editorial, aggregators, and merchant pages. The focus is on ratings, reviews, and basic pricing. This is what gives you star ratings and price ranges in organic search results.
Merchant Listing — only available on pages where a customer can actually buy the product. The focus shifts to offer details: price, availability, shipping, returns. This is what gets you into Google Shopping surfaces, Popular Products, and price drop badges.
A merchant product page can qualify for both. Adding the required Merchant Listing properties (price, availability, a valid Offer) also makes the page eligible for Product Snippets. There's no conflict between them; Merchant Listing is a superset.
If you're not ready to implement full variant markup yet, AggregateOffer is a simpler fallback: a single Product with lowPrice and highPrice to indicate a price range. You won't get Shopping swatches, but Google can at least show the range in search results. It's a pragmatic interim step — particularly for comparison pages or if your CMS can't expose individual variant data to the template.
ProductGroup: the parent of variants
ProductGroup exists for one purpose: to tell Google that multiple Product entities are variations of the same parent item. A T-shirt in five colours. A laptop in three storage configurations. A sofa in two fabrics. (The full implementation guide is on the Product Variants schema page.)
Without ProductGroup, Google sees each variant as a separate, unrelated product. It has no way to associate the blue version with the red version, or to know that both cost the same and share the same reviews. With ProductGroup, Google can present those variants together — colour swatches in Shopping, variant-specific pricing, and a unified product identity across its knowledge graph.
ProductGroup takes three key properties that Product doesn't have:
variesBy— what dimension(s) the variants differ on. Google supports specific schema.org URIs:https://schema.org/color,https://schema.org/size,https://schema.org/material,https://schema.org/pattern,https://schema.org/suggestedAge, andhttps://schema.org/suggestedGender. These must be full URIs, not plain text.hasVariant— the individualProductentities that belong to this group.productGroupID— a shared identifier across all variants, typically the parent SKU or style code.
| variesBy URI | Variant property | Example value |
|---|---|---|
https://schema.org/color |
color |
Forest Green |
https://schema.org/size |
size |
42 EU |
https://schema.org/material |
material |
Organic Cotton |
https://schema.org/pattern |
pattern |
Striped |
https://schema.org/suggestedAge |
suggestedAge |
3–5 years |
https://schema.org/suggestedGender |
suggestedGender |
female |
The variants themselves are still full Product objects with their own SKU, GTIN, price, image, colour, size, and availability. ProductGroup doesn't replace any of that — it wraps it.
A note on GTINs: schema.org supports gtin as a generic property, plus specific lengths — gtin8, gtin12 (UPC), gtin13 (EAN), and gtin14. Google recommends using the most specific one that matches your barcode. If your products have EAN codes, use gtin13. If they have UPCs, use gtin12. The examples below use gtin13, but substitute whichever applies. This is how Google matches your structured data to your Merchant Center feed — if the GTIN in your schema doesn't match the GTIN in your feed, Google treats them as different products.
One practical benefit: properties that apply to the entire group — brand, aggregateRating, hasMerchantReturnPolicy, shippingDetails via OfferShippingDetails — can sit on the ProductGroup rather than being duplicated across every variant. This is especially useful for Merchant Listing implementations where shipping and returns are recommended properties.
The decision tree
This is how I approach it in practice:
Does the product come in variants a shopper selects (colour, size, material)? Use ProductGroup with Product entities inside it via hasVariant. Each variant needs its own Offer, its own image, and its own identifying properties.
Is the product a single SKU with no meaningful variations? Use Product alone. Don't wrap it in a ProductGroup. There's nothing to group.
Does the product have options that don't constitute real variants? This is where it gets judgmental. Gift wrapping, extended warranty, personalised engraving — these are add-ons, not variants. They don't change the core product. Don't model them as ProductGroup variants. Keep them as a single Product with one Offer.
Are you a reviewer, aggregator, or editorial site? Use Product with Product Snippet properties. ProductGroup and Merchant Listing don't apply to you.
Decision tree: which markup pattern to use
Single page vs multi-page variants
This is where implementations start to diverge and where I see the most mistakes.
Two implementation patterns for variant markup
Single-page (all variants on one URL)
The most common e-commerce pattern. A product page at /shoes/trail-runner/ with a colour dropdown. The URL doesn't change when you select a different colour, or it appends a parameter like ?color=blue.
For this setup, the ProductGroup and all its variant Product objects live in the same JSON-LD block on the same page. The canonical URL should be the base URL without variant parameters — that's the URL that represents the ProductGroup as a whole.
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"name": "Trail Runner Pro",
"productGroupID": "TR-PRO-2026",
"variesBy": ["https://schema.org/color"],
"hasVariant": [
{
"@type": "Product",
"name": "Trail Runner Pro — Forest Green",
"color": "Forest Green",
"sku": "TR-PRO-GRN",
"gtin13": "5901234123457",
"image": "https://example.com/img/tr-pro-green.jpg",
"offers": {
"@type": "Offer",
"price": 129.00,
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock"
}
},
{
"@type": "Product",
"name": "Trail Runner Pro — Slate Blue",
"color": "Slate Blue",
"sku": "TR-PRO-BLU",
"gtin13": "5901234123464",
"image": "https://example.com/img/tr-pro-blue.jpg",
"offers": {
"@type": "Offer",
"price": 129.00,
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock"
}
}
]
}
Multi-page (each variant has its own URL)
Less common, but you see it on sites where each colour or configuration has a dedicated page: /shoes/trail-runner-green/ and /shoes/trail-runner-blue/.
Here each page carries its own Product markup and links back to the group using isVariantOf. There's no single page that holds the ProductGroup — each variant page references the group ID so Google can assemble the cluster from individual pages.
This is harder to get right. Every page needs to reference the same productGroupID. Every page needs valid isVariantOf pointing at a consistent ProductGroup entity. And canonicals matter here: each variant page must be self-canonical. If you canonical the blue page to the green page, you've collapsed the variant back into a single product and the group breaks.
Here's what the JSON-LD looks like on each variant page — note isVariantOf linking back to the group via a shared @id:
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "Trail Runner Pro — Forest Green",
"color": "Forest Green",
"sku": "TR-PRO-GRN",
"gtin13": "5901234123457",
"image": "https://example.com/img/tr-pro-green.jpg",
"isVariantOf": {
"@type": "ProductGroup",
"@id": "https://example.com/shoes/trail-runner/#group",
"name": "Trail Runner Pro",
"productGroupID": "TR-PRO-2026",
"variesBy": ["https://schema.org/color"]
},
"offers": {
"@type": "Offer",
"price": 129.00,
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock",
"url": "https://example.com/shoes/trail-runner-green/"
}
}
What actually breaks when you get this wrong
The frustrating thing about structured data errors in this area is that they're mostly silent. The markup validates. The Rich Results Test shows a green tick. But the features you expected don't appear.
Using Product alone when you should use ProductGroup: Google treats each variant as a separate, unrelated product. No colour swatches. No variant grouping in Shopping. If a user searches for "trail runner blue," Google might surface the green variant because it doesn't know the blue one exists as a related option.
Using ProductGroup for a product with no variants: Google may ignore the wrapper entirely and just read the single Product inside it. No harm, but also no benefit. It's unnecessary complexity in your templates that makes debugging harder when something actually breaks.
Using plain text values in variesBy instead of schema.org URIs: This is the most common implementation error I see. Writing "variesBy": "Color" instead of "variesBy": "https://schema.org/color". Valid JSON-LD. Passes syntax checks. But Google doesn't trigger Shopping swatches from plain text values. It needs the full URI to map the variation dimension to its internal product graph.
Missing offers on individual variants: The ProductGroup itself doesn't carry an Offer. Each variant Product needs its own. If you put the price on the group and omit it from the variants, Google has a group with no buyable items inside it.
Inconsistent productGroupID across pages: On multi-page implementations, if even one variant page references a different productGroupID — a typo, a template variable that resolves differently, a staging value that leaked to production — that variant falls out of the group. Google can't match it to the others.
| Mistake | What happens | Validates? |
|---|---|---|
| Product alone for variants | No colour swatches, no variant grouping in Shopping | Yes |
| ProductGroup with no variants | Google ignores the wrapper — no benefit, added complexity | Yes |
| Plain text in variesBy | No Shopping swatches — Google needs full URIs | Yes |
| Offer on group, not variants | Group has no buyable items — no Shopping features | Yes |
| Mismatched productGroupID | Variant falls out of the group silently | Yes |
Every mistake above passes validation. That's the problem.
The relationship to your GMC feed
This is worth mentioning because it comes up in every e-commerce audit. Your Google Merchant Center product feed also has a concept of "item group ID" that groups variants together. Google cross-references feed data with on-page structured data.
When your structured data's productGroupID matches your feed's item_group_id, Google has a consistent signal from two sources. When they don't match — or when one exists and the other doesn't — you're relying on Google to figure out the relationship on its own. It sometimes will. It sometimes won't.
The strongest implementations align both: the productGroupID in your schema matches the item_group_id in your feed, and the sku on each variant Product matches the id of the corresponding feed item. If you're already maintaining a GMC feed, you can audit this alignment with the feed analyzer.
A practical checklist
Before shipping product structured data on an e-commerce site, run through these:
- Every product page has
Productmarkup with a validOfferincluding price, currency, and availability. - Products with selectable variants (colour, size, material) use
ProductGroupas a wrapper. variesByuses full schema.org URIs, not plain text strings.- Each variant inside the group has its own SKU, GTIN, image, and offer — not inherited from the parent.
productGroupIDis consistent across all pages and matches your GMC feed'sitem_group_id.- Products without variants use
Productalone. No emptyProductGroupwrapper. - Every page with structured data has a self-referential canonical.
- Schema prices match the visible page price and the checkout price.
- Run the output through the Rich Results Test and the schema.org validator — green ticks don't guarantee Shopping features, but errors guarantee you won't get them.
A note on platforms
Shopify handles single-page variants natively — toggle a colour, the URL stays the same. Apps like JSON-LD for SEO map this to ProductGroup using inProductGroupWithID to link variants to the parent product ID. On WooCommerce, variable products map naturally to ProductGroup, but the option slugs need a mapping layer to translate them into schema.org URIs (e.g. mapping the pa_color attribute to https://schema.org/color). Most headless setups using separate variant URLs will need the flat isVariantOf structure shown above.
If those nine things on the checklist are true, you're ahead of most e-commerce sites I audit. The generators for Product Snippet, Merchant Listing, and Product Variants will produce valid output for all three patterns; the hard part isn't generating the markup, it's making sure your templates produce the right pattern for the right product type at scale.
Frequently asked questions
Do I need ProductGroup schema for a product with no variants?
No. ProductGroup exists to group variations of the same product — different colours, sizes, or materials. If a product has no selectable variants, use Product markup alone. Wrapping a single-SKU product in ProductGroup adds complexity without triggering any additional Google features.
Does ProductGroup replace Product schema?
No. ProductGroup wraps Product — it does not replace it. Each variant inside a ProductGroup is still a full Product object with its own SKU, GTIN, price, image, and availability. ProductGroup provides the parent container; Product provides the purchasable items.
What is the difference between hasVariant and isVariantOf?
hasVariant is used in nested structures where the ProductGroup contains variant Product objects directly. isVariantOf is used in flat structures where each variant Product is a separate JSON-LD object that links back to the ProductGroup via a shared @id. Both are valid — hasVariant is more common for single-page implementations, isVariantOf is typical for multi-page setups where each variant has its own URL.