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 documentation

Use 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" }
    }
  ]
};

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);

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.

Product variants properties in Google's documentation
PropertyGoogleExpectsNotes
nameRequiredTextThe name of the ProductGroup, for example Wool winter coat. Variant names are more specific.
aggregateRatingRecommendedAggregateRatingRepresentative of all variants. Follow the Review snippet guidelines.
aggregateRating.ratingValueRequiredNumber or TextThe average rating for the item.
aggregateRating.ratingCountOne ofNumberThe total number of ratings for the item on your site.
aggregateRating.reviewCountOne ofNumberThe number of people who provided a review with or without an accompanying rating.
aggregateRating.bestRatingRecommendedNumberThe highest value allowed in this rating system. If omitted, 5 is assumed.
aggregateRating.worstRatingRecommendedNumberThe lowest value allowed in this rating system. If omitted, 1 is assumed.
brandRecommendedBrandThe brand of the ProductGroup (same across all variants).
brand.nameRecommendedText
descriptionRecommendedText or TextObjectOf the ProductGroup; variant-specific descriptions go on the variant Product.
hasAdultConsiderationRecommendedEnumFor adult-oriented products. One of: SexualContentConsideration.
hasVariantRecommendedProduct, can repeatA nested Product that is one of the variants of the ProductGroup.
hasVariant.nameRequiredTextTo each variant Product.
hasVariant.skuRecommendedTextEach variant must have a unique ID. Or use gtin.
hasVariant.gtinRecommendedTextAs a variant identifier. Or use gtin8/12/13/14.
hasVariant.inProductGroupWithIDRecommendedTextMatching the group's productGroupID.
hasVariant.isVariantOfRecommendedProductGroupReferencing the ProductGroup by @id (multi-page sites).
hasVariant.urlRecommendedURLA distinct URL that preselects this variant.
hasVariant.offersRecommendedOfferPer variant (merchant listing requirements apply).
hasVariant.imageRecommendedURL or ImageObject, can repeatFor the variant.
productGroupIDRecommendedTextThe identifier of the product group (parent sku).
reviewRecommendedReview, can repeatOf the ProductGroup. Follow the Review snippet guidelines.
review.authorRequiredPerson or OrganizationThe author of the review. The reviewer's name must be a valid name. Up to 100 characters.
review.reviewRatingRequiredRatingThe rating given in this review.
review.datePublishedRecommendedDate or DateTimeThe date the review was published, in ISO 8601 format.
urlRecommendedURLSingle-page sites only: the URL (without variant selectors) where the ProductGroup is located. Don't use on multi-page sites.
variesByRecommendedDefinedTerm or URL, can repeatAspects by which the variants vary, as full Schema.org URLs. One of: color, size, suggestedAge, suggestedGender, material, pattern.

Explore Other Schema Framework Pages

Profile Page Use for creator or contributor profile pages where identity attributes and ownership are explicit. ProfilePage Open Q&A Use for pages where users submit questions and community or editorial answers are displayed. QAPage Open Recipe Use for recipe pages with complete ingredients, instructions, and preparation details. Recipe Open Return Policy Use to publish a merchant return policy once at the organization level, with offer-level overrides. MerchantReturnPolicy Open Review Snippet Use when a page publishes genuine reviews tied to a clearly identified product, service, or entity. Review Open Shipping Policy Use to publish shipping rates, delivery times and destinations once at the organization level. ShippingService Open Site Name Use on the home page so Google shows the preferred site name in search results. WebSite Open Retired Sitelinks Search Box Use on sites with a functioning internal search endpoint that can accept URL query parameters. WebSite Open Software App (Beta) Use for software product pages that describe platform support and application category clearly. SoftwareApplication Open Limited Speakable Use for news-style pages where specific text segments are appropriate for text-to-speech playback. SpeakableSpecification Open Subscription and Paywalled Content Use for pages with restricted sections so Google can distinguish free previews from protected content. CreativeWork Open Limited Vacation Rental Use for rental property detail pages where booking-relevant amenities and location signals are explicit. VacationRental Open Retired Vehicle Listing Use for inventory pages selling vehicles with standardized listing fields and offer information. Car Open Video Use for pages where a primary video is embedded and key media metadata is visible on-page. VideoObject Open Article Use for editorial pages where the visible content is a complete article with a clear byline and publication timestamps. Article Open Limited Book Actions Use when your page helps users read, preview, or purchase books through clearly described actions. Book Open Breadcrumb Use on pages that belong to a clear hierarchy and display navigational trail links users can follow. BreadcrumbList Open Carousel Use when a page lists multiple related entities in a scannable sequence that can be surfaced as cards. ItemList Open Course Use for educational offerings where curriculum details, provider identity, and delivery context are explicit. Course Open Dataset Use for dataset landing pages so datasets are discoverable in Google Dataset Search with license, creator and download details. Dataset Open Discussion Forum Use for forum and community threads where a primary post and subsequent replies are publicly visible. DiscussionForumPosting Open Limited Education Q&A Use for educational question-answer pages where a single canonical answer and supporting context are visible. Quiz Open Employer Aggregate Rating Use on pages that publish employer rating aggregates from a clear and policy-compliant review source. EmployerAggregateRating Open Retired Estimated Salary Use for compensation insight pages that present transparent salary ranges for specific occupations and locations. Occupation Open Event Use for upcoming events with a real-world or virtual occurrence, schedule, and location context. Event Open Retired Fact Check Use for fact-checking pages that evaluate a specific claim with a transparent rating methodology. ClaimReview Open Retired Home Activities Use for virtual classes and activities where participation details and timing are clearly listed. Event Open Retired How-To Use for instructional content that teaches a process through ordered steps and actionable detail. HowTo Open Image Metadata Use on image pages and image hosts that expose ownership and licensing information to search engines. ImageObject Open Job Posting Use for active job listings that include role details, hiring entity, and location/salary context. JobPosting Open Retired Learning Video Use for educational videos where the learning objective and pedagogical structure are explicit. LearningResource Open Local Business Use for physical businesses serving local customers with publicly verifiable contact and location details. LocalBusiness Open Loyalty Program Use to publish a loyalty program and its tiers once at the organization level, so member prices and member shipping can reference them. MemberProgram Open Math Solvers Use for pages that solve mathematical expressions and present step-by-step computational guidance. MathSolver Open Merchant Listing Use on pages where a product can be bought, with price, availability, shipping and return details. Product Open Limited Movie Use for movie detail pages with canonical film information and transparent metadata attribution. Movie Open Organization Use for official organization pages to clarify brand identity and authoritative entity signals. Organization Open Retired Practice Problems Use for educational practice content that presents solvable problems and answer validation structure. Quiz Open Product Snippet Use for product pages where pricing, availability, and product identity are visible to users. Product Open

Implement Product Variants Schema Frameworks Correctly

Get implementation support for conditional schema architecture, validation, and deployment governance.