Product Variants Schema Framework
Use conditional-logic schema generation to keep Product Variants markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralProduct Variants documentationUse on product pages that offer several variants of one product, for example a coat in different sizes and colors. Variant markup supplements Product structured data: a ProductGroup describes the parent, and each variant is a Product that must still meet merchant listing requirements (when shoppers can buy it on the page) or product snippet requirements. For the full Offer rules see the Merchant Listing framework; for editorial pages see the Product snippet framework.
Key Implementation Documentation Highlights
- ProductGroup required property: name (for example Wool winter coat; variant names are more specific). Recommended: productGroupID, variesBy, hasVariant, brand, description, aggregateRating, review, hasAdultConsideration, and url on single-page sites only.
- variesBy lists the aspects the variants differ on as full Schema.org URLs: https://schema.org/color, size, suggestedAge, suggestedGender, material and pattern. Each variant should state the matching property and differ from its siblings on it.
- hasVariant holds the variant Products. Each needs a name; recommended are sku or gtin, inProductGroupWithID, isVariantOf (a reference to the ProductGroup by @id), a distinct url, offers and image.
- Identifiers: every variant needs a unique ID (sku or gtin) and every product group a unique ID, given as productGroupID on the ProductGroup or inProductGroupWithID on the variants. When both are present they must match.
- Each variant is validated as a product in its own right: when shoppers can buy it, that means image and an Offer with a price greater than zero and priceCurrency, plus the recommended shipping, return, availability and identifier properties of merchant listings.
- The site must be able to preselect each variant directly with a distinct URL that shows the correct image, price and availability.
- Single-page approach: all variants live on one page with one distinct canonical URL for the ProductGroup. Set ProductGroup.url to that URL without variant selectors, give each variant a url that preselects it with query parameters (for example ?size=small&color=green), and don't change the markup dynamically as users pick variants.
- Multi-page approach: variants are spread across several pages, each with its own URL. Each page carries full, self-contained markup for the entities on it, repeats the ProductGroup definition and links variants to it with isVariantOf. Don't use ProductGroup.url.
- aggregateRating and review on the ProductGroup should represent all variants and follow the Review snippet guidelines; variant-specific descriptions go on the variant Product.
- Mark up pages that focus on a single product and its variants, not category or listing pages.
- Validate with the Rich Results Test and URL Inspection, then monitor the related Search Console report for errors and warnings after deployment.
Template Approach
const pageData = {
name: "Summit Merino Hoodie",
groupId: "TH-SUMMIT-HOOD",
url: "https://www.example.com/shop/summit-merino-hoodie"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": pageData.name,
"productGroupID": pageData.groupId,
"url": pageData.url,
"variesBy": ["https://schema.org/color"],
"aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.7, "reviewCount": 86 },
"hasVariant": [
{
"@type": "Product",
"name": pageData.name + ", Spruce",
"sku": "TH-SUMMIT-HOOD-SPR",
"color": "Spruce",
"url": pageData.url + "?color=spruce",
"image": "https://www.example.com/img/summit-hoodie-spruce.jpg",
"offers": { "@type": "Offer", "price": 129, "priceCurrency": "USD" }
},
{
"@type": "Product",
"name": pageData.name + ", Charcoal",
"sku": "TH-SUMMIT-HOOD-CHR",
"color": "Charcoal",
"url": pageData.url + "?color=charcoal",
"image": "https://www.example.com/img/summit-hoodie-charcoal.jpg",
"offers": { "@type": "Offer", "price": 129, "priceCurrency": "USD" }
}
]
};
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "Summit Merino Hoodie",
"productGroupID": "TH-SUMMIT-HOOD",
"url": "https://www.example.com/shop/summit-merino-hoodie",
"variesBy": [
"https://schema.org/color"
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.7,
"reviewCount": 86
},
"hasVariant": [
{
"@type": "Product",
"name": "Summit Merino Hoodie, Spruce",
"sku": "TH-SUMMIT-HOOD-SPR",
"color": "Spruce",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=spruce",
"image": "https://www.example.com/img/summit-hoodie-spruce.jpg",
"offers": {
"@type": "Offer",
"price": 129,
"priceCurrency": "USD"
}
},
{
"@type": "Product",
"name": "Summit Merino Hoodie, Charcoal",
"sku": "TH-SUMMIT-HOOD-CHR",
"color": "Charcoal",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=charcoal",
"image": "https://www.example.com/img/summit-hoodie-charcoal.jpg",
"offers": {
"@type": "Offer",
"price": 129,
"priceCurrency": "USD"
}
}
]
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
const S = "https://schema.org/";
const VARIES_BY = ["color", "size", "suggestedAge", "suggestedGender", "material", "pattern"];
const AVAILABILITY = ["BackOrder", "Discontinued", "InStock", "InStoreOnly", "LimitedAvailability", "OnlineOnly", "OutOfStock", "PreOrder", "PreSale", "SoldOut"];
const CONDITIONS = ["NewCondition", "RefurbishedCondition", "UsedCondition"];
const RETURN_CATEGORY = { finite: "MerchantReturnFiniteReturnWindow", not_permitted: "MerchantReturnNotPermitted", unlimited: "MerchantReturnUnlimitedWindow" };
const RETURN_FEES = { free: "FreeReturn", customer_pays: "ReturnFeesCustomerResponsibility" };
const GENDERS = ["male", "female", "unisex"];
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
const isCountryCode = (value) => typeof value === "string" && /^[A-Z]{2}$/.test(value);
const isGtin = (value) => /^(\d{8}|\d{12,14})$/.test(String(value ?? ""));
const isSku = (value) => typeof value === "string" && /^\S+$/.test(value);
const schemaEnum = (value, allowed) => (allowed.includes(value) ? S + value : null);
const wholeNumber = (value) => (Number.isInteger(value) && value >= 0 ? value : null);
const baseUrl = (url) => url.split(/[?#]/)[0];
// Rating or AggregateRating; null when ratingValue falls outside worstRating..bestRating.
function buildRating(input, type) {
if (!input) return null;
const best = toNumber(input.bestRating) ?? 5;
const worst = toNumber(input.worstRating) ?? 1;
const value = toNumber(input.ratingValue);
if (value === null || worst >= best || value < worst || value > best) return null;
const rating = { "@type": type, ratingValue: value, bestRating: best, worstRating: worst };
if (type === "AggregateRating") {
const ratingCount = toNumber(input.ratingCount);
const reviewCount = toNumber(input.reviewCount);
if (!(ratingCount > 0) && !(reviewCount > 0)) return null;
rating.ratingCount = ratingCount > 0 ? ratingCount : null;
rating.reviewCount = reviewCount > 0 ? reviewCount : null;
}
return rating;
}
// Review with a valid author name (under 100 characters) and an in-range rating.
function buildReview(input) {
const authorName = String(input?.authorName ?? "").trim();
const reviewRating = buildRating(input?.rating, "Rating");
if (!authorName || authorName.length >= 100 || !reviewRating) return null;
return {
"@type": "Review",
author: { "@type": input.authorType === "Organization" ? "Organization" : "Person", name: authorName },
reviewRating,
datePublished: toIsoDate(input.datePublished)
};
}
const daysRange = ([min, max] = []) =>
wholeNumber(min) !== null && wholeNumber(max) !== null && min <= max
? { "@type": "QuantitativeValue", minValue: min, maxValue: max, unitCode: "DAY" }
: null;
// Shared by every variant. Full shipping and return options: see the Merchant Listing framework.
function buildShipping(s) {
const rate = toNumber(s?.rate);
const handlingTime = daysRange(s?.handlingDays);
const transitTime = daysRange(s?.transitDays);
if (rate === null || rate < 0 || !isCurrencyCode(s.currency) || !isCountryCode(s.country)) return null;
if (!handlingTime && !transitTime) return null;
return {
"@type": "OfferShippingDetails",
shippingRate: { "@type": "MonetaryAmount", value: rate, currency: s.currency },
shippingDestination: { "@type": "DefinedRegion", addressCountry: s.country },
deliveryTime: { "@type": "ShippingDeliveryTime", handlingTime, transitTime }
};
}
function buildReturnPolicy(r) {
const category = RETURN_CATEGORY[r?.category];
const days = wholeNumber(r?.days);
if (!category || !isCountryCode(r.country)) return null;
if (category === "MerchantReturnFiniteReturnWindow" && !(days > 0)) return null;
return {
"@type": "MerchantReturnPolicy",
applicableCountry: r.country,
returnPolicyCategory: S + category,
merchantReturnDays: category === "MerchantReturnFiniteReturnWindow" ? days : null,
returnFees: RETURN_FEES[r.fees] ? S + RETURN_FEES[r.fees] : null
};
}
// Each variant meets merchant listing rules: one Offer, price > 0, ISO 4217 currency.
function buildVariantOffer(offer, url, shared) {
const price = toNumber(offer?.price);
if (!(price > 0) || !isCurrencyCode(offer.currency)) return null;
return {
"@type": "Offer",
url,
price,
priceCurrency: offer.currency,
availability: schemaEnum(offer.availability, AVAILABILITY),
itemCondition: schemaEnum(offer.condition ?? "NewCondition", CONDITIONS),
shippingDetails: buildShipping(shared.shipping),
hasMerchantReturnPolicy: buildReturnPolicy(shared.returns)
};
}
// Single-page: every variant is preselected on the group URL with query parameters (?color=green&size=m).
// Multi-page: each variant has its own page, and each page repeats the full group markup.
function chooseVariantApproach(source) {
const groupUrl = isAbsoluteUrl(source.url) ? baseUrl(source.url) : null;
const variants = source.variants ?? [];
const onGroupUrl = (v) => typeof v.url === "string" && v.url.startsWith(`${groupUrl}?`);
return groupUrl && variants.length > 0 && variants.every(onGroupUrl) ? "single-page" : "multi-page";
}
// Values for each variesBy property; null when a variant does not state one.
function variantAttributes(variant, variesBy) {
const attributes = {};
for (const key of variesBy) {
const value = variant[key];
if (value === null || value === undefined || value === "") return null;
attributes[key] = value;
}
return attributes;
}
function buildVariant(variant, ctx) {
const name = String(variant.name ?? "").trim();
const sku = isSku(variant.sku) ? variant.sku : null;
const gtin = isGtin(variant.gtin) ? String(variant.gtin) : null;
const images = (variant.images ?? []).filter(isAbsoluteUrl);
const attributes = variantAttributes(variant, ctx.variesBy);
const url = isAbsoluteUrl(variant.url) ? variant.url : null; // a distinct URL that preselects this variant
const offers = buildVariantOffer(variant.offer, url, ctx.shared);
if (!name || (!sku && !gtin) || !attributes || !url || images.length === 0 || !offers) return null;
const { suggestedGender, suggestedAge, ...direct } = attributes; // audience attributes go on PeopleAudience
const [minAge, maxAge] = suggestedAge ?? [];
return {
"@type": "Product",
name,
sku,
gtin,
image: images,
description: variant.description,
inProductGroupWithID: ctx.groupId,
isVariantOf: { "@id": ctx.groupRef },
...direct,
audience: suggestedGender || suggestedAge ? {
"@type": "PeopleAudience",
suggestedGender: GENDERS.includes(suggestedGender) ? suggestedGender : null,
suggestedMinAge: toNumber(minAge),
suggestedMaxAge: toNumber(maxAge)
} : null,
url,
offers
};
}
function buildProductGroupSchema(source) {
if (!passesPageGates(source)) return null;
const name = String(source.name ?? "").trim();
const groupId = isSku(source.groupId) ? source.groupId : null; // productGroupID, usually the parent sku
const variesBy = (source.variesBy ?? []).filter((v) => VARIES_BY.includes(v));
if (!name || !groupId || variesBy.length === 0 || !isAbsoluteUrl(source.url)) return null;
const approach = chooseVariantApproach(source);
const groupUrl = baseUrl(source.url);
const ctx = {
approach,
groupUrl,
groupId,
groupRef: `${new URL(groupUrl).origin}/#product-group-${encodeURIComponent(groupId)}`, // same @id on every page
variesBy,
shared: source
};
// Unique variant IDs (sku or gtin) and a distinct combination of variesBy values per variant.
const ids = new Set();
const combos = new Set();
const variants = [];
for (const input of source.variants ?? []) {
const variant = buildVariant(input, ctx);
if (!variant) continue;
const id = variant.sku ?? variant.gtin;
const combo = JSON.stringify(variesBy.map((key) => input[key]));
if (ids.has(id) || combos.has(combo)) continue;
ids.add(id);
combos.add(combo);
variants.push(variant);
}
if (variants.length < 2) return null; // one variant: use the Merchant Listing framework
return compact({
"@context": "https://schema.org",
"@type": "ProductGroup",
"@id": ctx.groupRef,
name,
description: source.description,
url: approach === "single-page" ? groupUrl : null, // single-page sites only, without variant selectors
brand: source.brand ? { "@type": "Brand", name: source.brand } : null,
aggregateRating: buildRating(source.rating, "AggregateRating"), // representative of all variants
review: (source.reviews ?? []).map(buildReview).filter(Boolean),
productGroupID: groupId,
variesBy: variesBy.map((key) => S + key),
hasVariant: variants
});
}
// Usage:
// const source = {
// url: "https://www.example.com/shop/summit-merino-hoodie",
// canonicalUrl: "https://www.example.com/shop/summit-merino-hoodie",
// indexable: true,
// contentVisible: true,
// name: "Summit Merino Hoodie",
// description: "Midweight merino wool hoodie with a scuba hood and thumb loops.",
// brand: "Trailhead Outfitters",
// groupId: "TH-SUMMIT-HOOD",
// variesBy: ["color", "size"],
// rating: { ratingValue: 4.7, reviewCount: 86 },
// reviews: [{ authorName: "Marcus Bell", rating: { ratingValue: 5 }, datePublished: "2026-09-02" }],
// shipping: { country: "US", rate: 0, currency: "USD", handlingDays: [0, 1], transitDays: [2, 5] },
// returns: { country: "US", category: "finite", days: 30, fees: "free" },
// variants: [
// {
// name: "Summit Merino Hoodie, Spruce, M",
// sku: "TH-SUMMIT-HOOD-SPR-M",
// gtin: "00812345670203",
// color: "Spruce",
// size: "M",
// url: "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=m",
// images: ["https://www.example.com/img/summit-hoodie-spruce.jpg"],
// offer: { price: 129, currency: "USD", availability: "InStock" }
// },
// {
// name: "Summit Merino Hoodie, Spruce, L",
// sku: "TH-SUMMIT-HOOD-SPR-L",
// gtin: "00812345670210",
// color: "Spruce",
// size: "L",
// url: "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=l",
// images: ["https://www.example.com/img/summit-hoodie-spruce.jpg"],
// offer: { price: 129, currency: "USD", availability: "LimitedAvailability" }
// },
// {
// name: "Summit Merino Hoodie, Charcoal, M",
// sku: "TH-SUMMIT-HOOD-CHR-M",
// gtin: "00812345670227",
// color: "Charcoal",
// size: "M",
// url: "https://www.example.com/shop/summit-merino-hoodie?color=charcoal&size=m",
// images: ["https://www.example.com/img/summit-hoodie-charcoal.jpg"],
// offer: { price: 119, currency: "USD", availability: "OutOfStock" }
// }
// ]
// };
// const jsonLd = buildProductGroupSchema(source);
import re
import json
from datetime import date, datetime
from urllib.parse import quote, urlsplit
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
S = "https://schema.org/"
VARIES_BY = ["color", "size", "suggestedAge", "suggestedGender", "material", "pattern"]
AVAILABILITY = ["BackOrder", "Discontinued", "InStock", "InStoreOnly", "LimitedAvailability", "OnlineOnly", "OutOfStock", "PreOrder", "PreSale", "SoldOut"]
CONDITIONS = ["NewCondition", "RefurbishedCondition", "UsedCondition"]
RETURN_CATEGORY = {"finite": "MerchantReturnFiniteReturnWindow", "not_permitted": "MerchantReturnNotPermitted", "unlimited": "MerchantReturnUnlimitedWindow"}
RETURN_FEES = {"free": "FreeReturn", "customer_pays": "ReturnFeesCustomerResponsibility"}
GENDERS = ["male", "female", "unisex"]
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
def is_country_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{2}", value) is not None
def is_gtin(value):
return re.fullmatch(r"\d{8}|\d{12,14}", str(value if value is not None else "")) is not None
def is_sku(value):
return isinstance(value, str) and re.fullmatch(r"\S+", value) is not None
def schema_enum(value, allowed):
return S + value if value in allowed else None
def whole_number(value):
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def base_url(url):
return re.split(r"[?#]", url)[0]
# Rating or AggregateRating; None when ratingValue falls outside worstRating..bestRating.
def build_rating(data, rating_type):
if not data:
return None
best = to_number(data.get("bestRating"))
best = 5 if best is None else best
worst = to_number(data.get("worstRating"))
worst = 1 if worst is None else worst
value = to_number(data.get("ratingValue"))
if value is None or worst >= best or value < worst or value > best:
return None
rating = {"@type": rating_type, "ratingValue": value, "bestRating": best, "worstRating": worst}
if rating_type == "AggregateRating":
rating_count = to_number(data.get("ratingCount")) or 0
review_count = to_number(data.get("reviewCount")) or 0
if rating_count <= 0 and review_count <= 0:
return None
rating["ratingCount"] = rating_count if rating_count > 0 else None
rating["reviewCount"] = review_count if review_count > 0 else None
return rating
# Review with a valid author name (under 100 characters) and an in-range rating.
def build_review(data):
data = data or {}
author_name = str(data.get("authorName") or "").strip()
review_rating = build_rating(data.get("rating"), "Rating")
if not author_name or len(author_name) >= 100 or not review_rating:
return None
return {
"@type": "Review",
"author": {"@type": "Organization" if data.get("authorType") == "Organization" else "Person", "name": author_name},
"reviewRating": review_rating,
"datePublished": to_iso_date(data.get("datePublished")),
}
def days_range(pair):
lo, hi = (list(pair or []) + [None, None])[:2]
if whole_number(lo) is not None and whole_number(hi) is not None and lo <= hi:
return {"@type": "QuantitativeValue", "minValue": lo, "maxValue": hi, "unitCode": "DAY"}
return None
# Shared by every variant. Full shipping and return options: see the Merchant Listing framework.
def build_shipping(s):
s = s or {}
rate = to_number(s.get("rate"))
handling_time = days_range(s.get("handlingDays"))
transit_time = days_range(s.get("transitDays"))
if rate is None or rate < 0 or not is_currency_code(s.get("currency")) or not is_country_code(s.get("country")):
return None
if not handling_time and not transit_time:
return None
return {
"@type": "OfferShippingDetails",
"shippingRate": {"@type": "MonetaryAmount", "value": rate, "currency": s["currency"]},
"shippingDestination": {"@type": "DefinedRegion", "addressCountry": s["country"]},
"deliveryTime": {"@type": "ShippingDeliveryTime", "handlingTime": handling_time, "transitTime": transit_time},
}
def build_return_policy(r):
r = r or {}
category = RETURN_CATEGORY.get(r.get("category"))
days = whole_number(r.get("days"))
if not category or not is_country_code(r.get("country")):
return None
if category == "MerchantReturnFiniteReturnWindow" and not (days is not None and days > 0):
return None
return {
"@type": "MerchantReturnPolicy",
"applicableCountry": r["country"],
"returnPolicyCategory": S + category,
"merchantReturnDays": days if category == "MerchantReturnFiniteReturnWindow" else None,
"returnFees": S + RETURN_FEES[r["fees"]] if r.get("fees") in RETURN_FEES else None,
}
# Each variant meets merchant listing rules: one Offer, price > 0, ISO 4217 currency.
def build_variant_offer(offer, url, shared):
offer = offer or {}
price = to_number(offer.get("price"))
if not (price is not None and price > 0) or not is_currency_code(offer.get("currency")):
return None
return {
"@type": "Offer",
"url": url,
"price": price,
"priceCurrency": offer["currency"],
"availability": schema_enum(offer.get("availability"), AVAILABILITY),
"itemCondition": schema_enum(offer.get("condition") or "NewCondition", CONDITIONS),
"shippingDetails": build_shipping(shared.get("shipping")),
"hasMerchantReturnPolicy": build_return_policy(shared.get("returns")),
}
# Single-page: every variant is preselected on the group URL with query parameters (?color=green&size=m).
# Multi-page: each variant has its own page, and each page repeats the full group markup.
def choose_variant_approach(source):
group_url = base_url(source["url"]) if is_absolute_url(source.get("url")) else None
variants = source.get("variants") or []
def on_group_url(v):
return isinstance(v.get("url"), str) and v["url"].startswith(f"{group_url}?")
return "single-page" if group_url and variants and all(on_group_url(v) for v in variants) else "multi-page"
# Values for each variesBy property; None when a variant does not state one.
def variant_attributes(variant, varies_by):
attributes = {}
for key in varies_by:
value = variant.get(key)
if value is None or value == "":
return None
attributes[key] = value
return attributes
def build_variant(variant, ctx):
name = str(variant.get("name") or "").strip()
sku = variant["sku"] if is_sku(variant.get("sku")) else None
gtin = str(variant["gtin"]) if is_gtin(variant.get("gtin")) else None
images = [i for i in (variant.get("images") or []) if is_absolute_url(i)]
attributes = variant_attributes(variant, ctx["variesBy"])
url = variant["url"] if is_absolute_url(variant.get("url")) else None # a distinct URL that preselects this variant
offers = build_variant_offer(variant.get("offer"), url, ctx["shared"])
if not name or (not sku and not gtin) or attributes is None or not url or not images or not offers:
return None
gender = attributes.pop("suggestedGender", None) # audience attributes go on PeopleAudience
age = attributes.pop("suggestedAge", None)
min_age, max_age = (list(age or []) + [None, None])[:2]
return {
"@type": "Product",
"name": name,
"sku": sku,
"gtin": gtin,
"image": images,
"description": variant.get("description"),
"inProductGroupWithID": ctx["groupId"],
"isVariantOf": {"@id": ctx["groupRef"]},
**attributes,
"audience": {
"@type": "PeopleAudience",
"suggestedGender": gender if gender in GENDERS else None,
"suggestedMinAge": to_number(min_age),
"suggestedMaxAge": to_number(max_age),
} if gender or age else None,
"url": url,
"offers": offers,
}
def build_product_group_schema(source):
if not passes_page_gates(source):
return None
name = str(source.get("name") or "").strip()
group_id = source["groupId"] if is_sku(source.get("groupId")) else None # productGroupID, usually the parent sku
varies_by = [v for v in (source.get("variesBy") or []) if v in VARIES_BY]
if not name or not group_id or not varies_by or not is_absolute_url(source.get("url")):
return None
approach = choose_variant_approach(source)
group_url = base_url(source["url"])
parts = urlsplit(group_url)
group_ref = f"{parts.scheme}://{parts.netloc}/#product-group-" + quote(group_id, safe="-_.!~*'()") # same @id on every page
ctx = {
"approach": approach,
"groupUrl": group_url,
"groupId": group_id,
"groupRef": group_ref,
"variesBy": varies_by,
"shared": source,
}
# Unique variant IDs (sku or gtin) and a distinct combination of variesBy values per variant.
ids, combos, variants = set(), set(), []
for item in source.get("variants") or []:
variant = build_variant(item, ctx)
if not variant:
continue
variant_id = variant["sku"] or variant["gtin"]
combo = json.dumps([item.get(key) for key in varies_by])
if variant_id in ids or combo in combos:
continue
ids.add(variant_id)
combos.add(combo)
variants.append(variant)
if len(variants) < 2:
return None # one variant: use the Merchant Listing framework
return compact({
"@context": "https://schema.org",
"@type": "ProductGroup",
"@id": ctx["groupRef"],
"name": name,
"description": source.get("description"),
"url": group_url if approach == "single-page" else None, # single-page sites only, without variant selectors
"brand": {"@type": "Brand", "name": source["brand"]} if source.get("brand") else None,
"aggregateRating": build_rating(source.get("rating"), "AggregateRating"), # representative of all variants
"review": [r for r in (build_review(x) for x in (source.get("reviews") or [])) if r],
"productGroupID": group_id,
"variesBy": [S + key for key in varies_by],
"hasVariant": variants,
})
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"@id": "https://www.example.com/#product-group-TH-SUMMIT-HOOD",
"name": "Summit Merino Hoodie",
"description": "Midweight merino wool hoodie with a scuba hood and thumb loops.",
"url": "https://www.example.com/shop/summit-merino-hoodie",
"brand": {
"@type": "Brand",
"name": "Trailhead Outfitters"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.7,
"bestRating": 5,
"worstRating": 1,
"reviewCount": 86
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Marcus Bell"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-09-02"
}
],
"productGroupID": "TH-SUMMIT-HOOD",
"variesBy": [
"https://schema.org/color",
"https://schema.org/size"
],
"hasVariant": [
{
"@type": "Product",
"name": "Summit Merino Hoodie, Spruce, M",
"sku": "TH-SUMMIT-HOOD-SPR-M",
"gtin": "00812345670203",
"image": [
"https://www.example.com/img/summit-hoodie-spruce.jpg"
],
"inProductGroupWithID": "TH-SUMMIT-HOOD",
"isVariantOf": {
"@id": "https://www.example.com/#product-group-TH-SUMMIT-HOOD"
},
"color": "Spruce",
"size": "M",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=m",
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=m",
"price": 129,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": 0,
"currency": "USD"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
}
}
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnFees": "https://schema.org/FreeReturn"
}
}
},
{
"@type": "Product",
"name": "Summit Merino Hoodie, Spruce, L",
"sku": "TH-SUMMIT-HOOD-SPR-L",
"gtin": "00812345670210",
"image": [
"https://www.example.com/img/summit-hoodie-spruce.jpg"
],
"inProductGroupWithID": "TH-SUMMIT-HOOD",
"isVariantOf": {
"@id": "https://www.example.com/#product-group-TH-SUMMIT-HOOD"
},
"color": "Spruce",
"size": "L",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=l",
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=spruce&size=l",
"price": 129,
"priceCurrency": "USD",
"availability": "https://schema.org/LimitedAvailability",
"itemCondition": "https://schema.org/NewCondition",
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": 0,
"currency": "USD"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
}
}
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnFees": "https://schema.org/FreeReturn"
}
}
},
{
"@type": "Product",
"name": "Summit Merino Hoodie, Charcoal, M",
"sku": "TH-SUMMIT-HOOD-CHR-M",
"gtin": "00812345670227",
"image": [
"https://www.example.com/img/summit-hoodie-charcoal.jpg"
],
"inProductGroupWithID": "TH-SUMMIT-HOOD",
"isVariantOf": {
"@id": "https://www.example.com/#product-group-TH-SUMMIT-HOOD"
},
"color": "Charcoal",
"size": "M",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=charcoal&size=m",
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/summit-merino-hoodie?color=charcoal&size=m",
"price": 119,
"priceCurrency": "USD",
"availability": "https://schema.org/OutOfStock",
"itemCondition": "https://schema.org/NewCondition",
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": 0,
"currency": "USD"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
}
}
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnFees": "https://schema.org/FreeReturn"
}
}
}
]
}
Why Conditional Logic Is Better Than Static Templates
- Picks the single-page or multi-page pattern from the actual variant URLs, so ProductGroup.url only appears when every variant is preselected on one canonical URL.
- Drops variants that lack a unique sku or gtin, a distinct URL, an image or a valid Offer, and drops duplicate IDs or duplicate variesBy combinations.
- Keeps productGroupID, inProductGroupWithID and isVariantOf aligned from one group ID, which hand-written templates often let drift apart.
- Returns nothing when fewer than two valid variants remain, so a lone product goes through the Merchant Listing framework instead of becoming a group of one.
Property Reference
What Google's Product variants documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: In aggregateRating: at least one of ratingCount or reviewCount is required.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The name of the ProductGroup, for example Wool winter coat. Variant names are more specific. |
| aggregateRating | Recommended | AggregateRating | Representative of all variants. Follow the Review snippet guidelines. |
| aggregateRating.ratingValue | Required | Number or Text | The average rating for the item. |
| aggregateRating.ratingCount | One of | Number | The total number of ratings for the item on your site. |
| aggregateRating.reviewCount | One of | Number | The number of people who provided a review with or without an accompanying rating. |
| aggregateRating.bestRating | Recommended | Number | The highest value allowed in this rating system. If omitted, 5 is assumed. |
| aggregateRating.worstRating | Recommended | Number | The lowest value allowed in this rating system. If omitted, 1 is assumed. |
| brand | Recommended | Brand | The brand of the ProductGroup (same across all variants). |
| brand.name | Recommended | Text | |
| description | Recommended | Text or TextObject | Of the ProductGroup; variant-specific descriptions go on the variant Product. |
| hasAdultConsideration | Recommended | Enum | For adult-oriented products. One of: SexualContentConsideration. |
| hasVariant | Recommended | Product, can repeat | A nested Product that is one of the variants of the ProductGroup. |
| hasVariant.name | Required | Text | To each variant Product. |
| hasVariant.sku | Recommended | Text | Each variant must have a unique ID. Or use gtin. |
| hasVariant.gtin | Recommended | Text | As a variant identifier. Or use gtin8/12/13/14. |
| hasVariant.inProductGroupWithID | Recommended | Text | Matching the group's productGroupID. |
| hasVariant.isVariantOf | Recommended | ProductGroup | Referencing the ProductGroup by @id (multi-page sites). |
| hasVariant.url | Recommended | URL | A distinct URL that preselects this variant. |
| hasVariant.offers | Recommended | Offer | Per variant (merchant listing requirements apply). |
| hasVariant.image | Recommended | URL or ImageObject, can repeat | For the variant. |
| productGroupID | Recommended | Text | The identifier of the product group (parent sku). |
| review | Recommended | Review, can repeat | Of the ProductGroup. Follow the Review snippet guidelines. |
| review.author | Required | Person or Organization | The author of the review. The reviewer's name must be a valid name. Up to 100 characters. |
| review.reviewRating | Required | Rating | The rating given in this review. |
| review.datePublished | Recommended | Date or DateTime | The date the review was published, in ISO 8601 format. |
| url | Recommended | URL | Single-page sites only: the URL (without variant selectors) where the ProductGroup is located. Don't use on multi-page sites. |
| variesBy | Recommended | DefinedTerm or URL, can repeat | Aspects by which the variants vary, as full Schema.org URLs. One of: color, size, suggestedAge, suggestedGender, material, pattern. |