Product Snippet Schema Framework
Use conditional-logic schema generation to keep Product Snippet markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralProduct Snippet documentationUse for pages about a single product. Pages where people can't buy the product directly (editorial reviews) target product snippets; pages where a shopper can buy it target merchant listings, which also make the page eligible for product snippets.
Key Implementation Documentation Highlights
- Product snippet required properties: name, plus one of review, aggregateRating or offers. Recommended: brand, description, image, sku, mpn, gtin.
- Product snippet offers can be an Offer (price or priceSpecification.price, 0 for free products; priceCurrency recommended) or an AggregateOffer (lowPrice and priceCurrency required; highPrice and offerCount recommended). Use Offer to be eligible for the price drop enhancement.
- Merchant listing required properties: name, image, offers. offers must be an Offer, not AggregateOffer, with a price greater than zero and priceCurrency (directly or through priceSpecification). Recommended: shippingDetails and hasMerchantReturnPolicy (or Organization-level policies), availability, itemCondition, brand, gtin, color, size and more.
- Value rules: priceCurrency is ISO 4217; availability and itemCondition take one schema.org value each; priceValidUntil must not be in the past; gtin is numeric with 8, 12, 13 or 14 digits; sku has no whitespace; a finite return window needs merchantReturnDays; ReturnShippingFees needs returnShippingFeesAmount.
- Pros and cons (positiveNotes, negativeNotes) are for editorial product review pages only, need at least two statements in total, and should be visible on the page.
- Variants: wrap them in ProductGroup (name required; productGroupID, variesBy as full URLs such as https://schema.org/color, and hasVariant recommended). Each variant is a Product with a unique sku or gtin and a URL that preselects it, and must meet merchant listing or product snippet requirements on its own.
- Mark up pages that focus on a single product or its variants, not category or listing pages. Prefer markup in the initial HTML: JavaScript-generated markup can make Shopping crawls less frequent and less reliable for price and availability.
- Reviews and ratings follow the Review snippet rules: author name under 100 characters, ratingValue within worstRating and bestRating (1 to 5 by default), and ratingCount or reviewCount on aggregateRating.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Template Approach
const pageData = {
title: "Northwind Trail 2 Running Shoe",
image: "https://www.example.com/img/northwind-trail-2.jpg",
brand: "Northwind",
reviewer: "Jordan Lee",
reviewScore: "4.5",
averageScore: "4.4",
reviewCount: "57"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Product",
"name": pageData.title,
"image": pageData.image,
"brand": { "@type": "Brand", "name": pageData.brand },
"review": {
"@type": "Review",
"author": { "@type": "Person", "name": pageData.reviewer },
"reviewRating": { "@type": "Rating", "ratingValue": pageData.reviewScore }
},
"aggregateRating": { "@type": "AggregateRating", "ratingValue": pageData.averageScore, "reviewCount": pageData.reviewCount }
};
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Northwind Trail 2 Running Shoe",
"image": "https://www.example.com/img/northwind-trail-2.jpg",
"brand": {
"@type": "Brand",
"name": "Northwind"
},
"review": {
"@type": "Review",
"author": {
"@type": "Person",
"name": "Jordan Lee"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": "4.5"
}
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.4",
"reviewCount": "57"
}
}
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;
}
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);
// 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 AVAILABILITY = ["BackOrder", "Discontinued", "InStock", "InStoreOnly", "LimitedAvailability", "OnlineOnly", "OutOfStock", "PreOrder", "PreSale", "SoldOut"];
const CONDITIONS = ["NewCondition", "RefurbishedCondition", "UsedCondition"];
const VARIES_BY = ["color", "size", "suggestedAge", "suggestedGender", "material", "pattern"];
const RETURN_CATEGORY = { finite: "MerchantReturnFiniteReturnWindow", none: "MerchantReturnNotPermitted", unlimited: "MerchantReturnUnlimitedWindow" };
const RETURN_FEES = { free: "FreeReturn", customer: "ReturnFeesCustomerResponsibility", shipping: "ReturnShippingFees" };
const RETURN_METHOD = { kiosk: "ReturnAtKiosk", mail: "ReturnByMail", store: "ReturnInStore" };
const schemaEnum = (value, allowed) => (allowed.includes(value) ? `https://schema.org/${value}` : null);
const isGtin = (value) => /^(\d{8}|\d{12,14})$/.test(String(value ?? ""));
const isSku = (value) => typeof value === "string" && /^\S+$/.test(value);
// Merchant listing: the shopper can buy on this page. Product snippet: editorial or review page.
// More than one variant of the same product makes the top-level entity a ProductGroup.
function chooseProductType(source) {
return {
entity: (source.variants ?? []).length > 1 ? "ProductGroup" : "Product",
listing: source.purchasable ? "merchant" : "snippet"
};
}
const daysRange = ([min, max] = []) =>
Number.isInteger(min) && Number.isInteger(max) && min >= 0 && max >= min
? { "@type": "QuantitativeValue", minValue: min, maxValue: max, unitCode: "DAY" }
: null;
function buildShipping(s) {
const rate = toNumber(s?.rate);
if (rate === null || rate < 0 || !isCurrencyCode(s.currency) || !/^[A-Z]{2}$/.test(s.country ?? "")) return null;
return {
"@type": "OfferShippingDetails",
shippingRate: { "@type": "MonetaryAmount", value: rate, currency: s.currency },
shippingDestination: { "@type": "DefinedRegion", addressCountry: s.country },
deliveryTime: { "@type": "ShippingDeliveryTime", handlingTime: daysRange(s.handlingDays), transitTime: daysRange(s.transitDays) }
};
}
// Finite windows need merchantReturnDays; ReturnShippingFees needs returnShippingFeesAmount.
function buildReturnPolicy(r) {
const category = RETURN_CATEGORY[r?.category];
const days = toNumber(r?.days);
const fee = toNumber(r?.feeAmount);
if (!category || !/^[A-Z]{2}$/.test(r.country ?? "")) return null;
if (category === "MerchantReturnFiniteReturnWindow" && !(Number.isInteger(days) && days > 0)) return null;
if (r.fees === "shipping" && !(fee !== null && fee >= 0 && isCurrencyCode(r.currency))) return null;
return {
"@type": "MerchantReturnPolicy",
applicableCountry: r.country,
returnPolicyCategory: `https://schema.org/${category}`,
merchantReturnDays: category === "MerchantReturnFiniteReturnWindow" ? days : null,
returnMethod: RETURN_METHOD[r.method] ? `https://schema.org/${RETURN_METHOD[r.method]}` : null,
returnFees: RETURN_FEES[r.fees] ? `https://schema.org/${RETURN_FEES[r.fees]}` : null,
returnShippingFeesAmount: r.fees === "shipping" ? { "@type": "MonetaryAmount", value: fee, currency: r.currency } : null
};
}
// Merchant listings need an Offer with price > 0 and priceCurrency; snippets accept price 0 or an AggregateOffer.
function buildOffer(offer, listing, shared, now) {
if (!offer) return null;
if (offer.lowPrice !== undefined) {
const low = toNumber(offer.lowPrice);
const high = toNumber(offer.highPrice);
if (listing === "merchant" || low === null || low < 0 || !isCurrencyCode(offer.currency)) return null;
return { "@type": "AggregateOffer", lowPrice: low, highPrice: high >= low ? high : null, offerCount: toNumber(offer.offerCount), priceCurrency: offer.currency };
}
const price = toNumber(offer.price);
const currency = isCurrencyCode(offer.currency) ? offer.currency : null;
if (price === null || price < 0) return null;
if (listing === "merchant" && (price <= 0 || !currency)) return null;
const validUntil = toIsoDate(offer.priceValidUntil);
return {
"@type": "Offer",
url: isAbsoluteUrl(offer.url) ? offer.url : null,
price,
priceCurrency: currency,
priceValidUntil: validUntil && new Date(`${validUntil}T23:59:59Z`) >= now ? validUntil : null,
availability: schemaEnum(offer.availability, AVAILABILITY),
itemCondition: schemaEnum(offer.condition, CONDITIONS),
shippingDetails: listing === "merchant" ? buildShipping(shared.shipping) : null,
hasMerchantReturnPolicy: listing === "merchant" ? buildReturnPolicy(shared.returns) : null
};
}
// Pros and cons: editorial reviews only, and at least two statements across both lists.
function buildEditorialReview(input, listing) {
const review = buildReview(input);
if (!review) return null;
const notes = (list) => (list ?? []).filter(Boolean).map((name, i) => ({ "@type": "ListItem", position: i + 1, name }));
const pros = notes(input.pros);
const cons = notes(input.cons);
if (listing !== "snippet" || pros.length + cons.length < 2) return review;
return {
...review,
positiveNotes: pros.length ? { "@type": "ItemList", itemListElement: pros } : null,
negativeNotes: cons.length ? { "@type": "ItemList", itemListElement: cons } : null
};
}
function buildVariant(variant, listing, shared, groupId, variesBy, now) {
const images = (variant.images ?? []).filter(isAbsoluteUrl);
const offers = buildOffer(variant.offer, listing, shared, now);
if (!variant.name || (!isSku(variant.sku) && !isGtin(variant.gtin))) return null;
if (listing === "merchant" && (images.length === 0 || offers?.["@type"] !== "Offer")) return null;
const attributes = Object.fromEntries(variesBy.map((key) => [key, variant[key]]));
return {
"@type": "Product",
name: variant.name,
sku: isSku(variant.sku) ? variant.sku : null,
gtin: isGtin(variant.gtin) ? String(variant.gtin) : null,
inProductGroupWithID: groupId,
...attributes,
url: isAbsoluteUrl(variant.url) ? variant.url : null,
image: images,
offers
};
}
function buildProductSchema(source, now = new Date()) {
if (!passesPageGates(source)) return null;
const { entity, listing } = chooseProductType(source);
const name = String(source.name ?? "").trim();
const images = (source.images ?? []).filter(isAbsoluteUrl);
if (!name) return null;
const aggregateRating = buildRating(source.rating, "AggregateRating");
const reviews = (source.reviews ?? []).map((r) => buildEditorialReview(r, listing)).filter(Boolean);
const shared = {
"@context": "https://schema.org",
"@type": entity,
name,
image: images,
description: source.description,
brand: source.brand ? { "@type": "Brand", name: source.brand } : null,
aggregateRating,
review: reviews
};
if (entity === "ProductGroup") {
const groupId = isSku(source.groupId) ? source.groupId : null;
const variesBy = (source.variesBy ?? []).filter((v) => VARIES_BY.includes(v));
const ids = new Set();
const variants = source.variants
.map((v) => buildVariant(v, listing, source, groupId, variesBy, now))
.filter((v) => {
const id = v?.sku ?? v?.gtin; // each variant needs a unique ID
if (!v || ids.has(id)) return false;
ids.add(id);
return true;
});
if (!groupId || variants.length === 0) return null;
return compact({
...shared,
productGroupID: groupId,
url: source.variantPages ? null : source.url, // single-page sites only
variesBy: variesBy.map((v) => `https://schema.org/${v}`),
hasVariant: variants
});
}
// Merchant listing requires image and a valid Offer; product snippet requires review, rating or offers.
const offers = buildOffer(source.offer, listing, source, now);
if (listing === "merchant" && (images.length === 0 || offers?.["@type"] !== "Offer")) return null;
if (listing === "snippet" && !offers && !aggregateRating && reviews.length === 0) return null;
return compact({
...shared,
sku: isSku(source.sku) ? source.sku : null,
mpn: source.mpn,
gtin: isGtin(source.gtin) ? String(source.gtin) : null,
offers
});
}
// Usage (editorial review page, so a product snippet with pros and cons):
// const source = {
// url: "https://www.example.com/reviews/northwind-trail-2",
// canonicalUrl: "https://www.example.com/reviews/northwind-trail-2",
// indexable: true,
// contentVisible: true,
// purchasable: false,
// name: "Northwind Trail 2 Running Shoe",
// description: "A cushioned trail running shoe with a rock plate and a 6 mm drop.",
// brand: "Northwind",
// images: ["https://www.example.com/img/northwind-trail-2.jpg"],
// sku: "NW-TRAIL2",
// mpn: "TR2-2026",
// gtin: "00812345678905",
// rating: { ratingValue: 4.4, reviewCount: 57 },
// reviews: [{
// authorName: "Jordan Lee",
// rating: { ratingValue: 4.5 },
// datePublished: "2026-09-22",
// pros: ["Stable on loose descents", "Grippy outsole in the wet"],
// cons: ["Runs half a size small"]
// }]
// };
// const jsonLd = buildProductSchema(source);
import re
from datetime import date, datetime
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
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
# 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")),
}
from datetime import datetime, timezone
AVAILABILITY = ["BackOrder", "Discontinued", "InStock", "InStoreOnly", "LimitedAvailability", "OnlineOnly", "OutOfStock", "PreOrder", "PreSale", "SoldOut"]
CONDITIONS = ["NewCondition", "RefurbishedCondition", "UsedCondition"]
VARIES_BY = ["color", "size", "suggestedAge", "suggestedGender", "material", "pattern"]
RETURN_CATEGORY = {"finite": "MerchantReturnFiniteReturnWindow", "none": "MerchantReturnNotPermitted", "unlimited": "MerchantReturnUnlimitedWindow"}
RETURN_FEES = {"free": "FreeReturn", "customer": "ReturnFeesCustomerResponsibility", "shipping": "ReturnShippingFees"}
RETURN_METHOD = {"kiosk": "ReturnAtKiosk", "mail": "ReturnByMail", "store": "ReturnInStore"}
def schema_enum(value, allowed):
return f"https://schema.org/{value}" if value in allowed else None
def is_gtin(value):
return re.fullmatch(r"\d{8}|\d{12,14}", str(value or "")) is not None
def is_sku(value):
return isinstance(value, str) and re.fullmatch(r"\S+", value) is not None
# Merchant listing: the shopper can buy on this page. Product snippet: editorial or review page.
# More than one variant of the same product makes the top-level entity a ProductGroup.
def choose_product_type(source):
return {
"entity": "ProductGroup" if len(source.get("variants") or []) > 1 else "Product",
"listing": "merchant" if source.get("purchasable") else "snippet",
}
def days_range(pair):
lo, hi = (list(pair or []) + [None, None])[:2]
if isinstance(lo, int) and isinstance(hi, int) and 0 <= lo <= hi:
return {"@type": "QuantitativeValue", "minValue": lo, "maxValue": hi, "unitCode": "DAY"}
return None
def build_shipping(s):
s = s or {}
rate = to_number(s.get("rate"))
if rate is None or rate < 0 or not is_currency_code(s.get("currency")) or not re.fullmatch(r"[A-Z]{2}", s.get("country") or ""):
return None
return {
"@type": "OfferShippingDetails",
"shippingRate": {"@type": "MonetaryAmount", "value": rate, "currency": s["currency"]},
"shippingDestination": {"@type": "DefinedRegion", "addressCountry": s["country"]},
"deliveryTime": {"@type": "ShippingDeliveryTime", "handlingTime": days_range(s.get("handlingDays")), "transitTime": days_range(s.get("transitDays"))},
}
# Finite windows need merchantReturnDays; ReturnShippingFees needs returnShippingFeesAmount.
def build_return_policy(r):
r = r or {}
category = RETURN_CATEGORY.get(r.get("category"))
days = to_number(r.get("days"))
fee = to_number(r.get("feeAmount"))
if not category or not re.fullmatch(r"[A-Z]{2}", r.get("country") or ""):
return None
if category == "MerchantReturnFiniteReturnWindow" and not (isinstance(days, int) and days > 0):
return None
if r.get("fees") == "shipping" and not (fee is not None and fee >= 0 and is_currency_code(r.get("currency"))):
return None
return {
"@type": "MerchantReturnPolicy",
"applicableCountry": r["country"],
"returnPolicyCategory": f"https://schema.org/{category}",
"merchantReturnDays": days if category == "MerchantReturnFiniteReturnWindow" else None,
"returnMethod": f"https://schema.org/{RETURN_METHOD[r['method']]}" if r.get("method") in RETURN_METHOD else None,
"returnFees": f"https://schema.org/{RETURN_FEES[r['fees']]}" if r.get("fees") in RETURN_FEES else None,
"returnShippingFeesAmount": {"@type": "MonetaryAmount", "value": fee, "currency": r["currency"]} if r.get("fees") == "shipping" else None,
}
# Merchant listings need an Offer with price > 0 and priceCurrency; snippets accept price 0 or an AggregateOffer.
def build_offer(offer, listing, shared, now):
if not offer:
return None
if "lowPrice" in offer:
low = to_number(offer.get("lowPrice"))
high = to_number(offer.get("highPrice"))
if listing == "merchant" or low is None or low < 0 or not is_currency_code(offer.get("currency")):
return None
return {"@type": "AggregateOffer", "lowPrice": low, "highPrice": high if high is not None and high >= low else None,
"offerCount": to_number(offer.get("offerCount")), "priceCurrency": offer["currency"]}
price = to_number(offer.get("price"))
currency = offer.get("currency") if is_currency_code(offer.get("currency")) else None
if price is None or price < 0:
return None
if listing == "merchant" and (price <= 0 or not currency):
return None
valid_until = to_iso_date(offer.get("priceValidUntil"))
still_valid = valid_until and datetime.fromisoformat(f"{valid_until}T23:59:59+00:00") >= now
return {
"@type": "Offer",
"url": offer.get("url") if is_absolute_url(offer.get("url")) else None,
"price": price,
"priceCurrency": currency,
"priceValidUntil": valid_until if still_valid else None,
"availability": schema_enum(offer.get("availability"), AVAILABILITY),
"itemCondition": schema_enum(offer.get("condition"), CONDITIONS),
"shippingDetails": build_shipping(shared.get("shipping")) if listing == "merchant" else None,
"hasMerchantReturnPolicy": build_return_policy(shared.get("returns")) if listing == "merchant" else None,
}
# Pros and cons: editorial reviews only, and at least two statements across both lists.
def build_editorial_review(data, listing):
review = build_review(data)
if not review:
return None
def notes(items):
return [{"@type": "ListItem", "position": i + 1, "name": n} for i, n in enumerate([x for x in items or [] if x])]
pros, cons = notes(data.get("pros")), notes(data.get("cons"))
if listing != "snippet" or len(pros) + len(cons) < 2:
return review
return {
**review,
"positiveNotes": {"@type": "ItemList", "itemListElement": pros} if pros else None,
"negativeNotes": {"@type": "ItemList", "itemListElement": cons} if cons else None,
}
def build_variant(variant, listing, shared, group_id, varies_by, now):
images = [u for u in variant.get("images") or [] if is_absolute_url(u)]
offers = build_offer(variant.get("offer"), listing, shared, now)
if not variant.get("name") or (not is_sku(variant.get("sku")) and not is_gtin(variant.get("gtin"))):
return None
if listing == "merchant" and (not images or (offers or {}).get("@type") != "Offer"):
return None
return {
"@type": "Product",
"name": variant["name"],
"sku": variant["sku"] if is_sku(variant.get("sku")) else None,
"gtin": str(variant["gtin"]) if is_gtin(variant.get("gtin")) else None,
"inProductGroupWithID": group_id,
**{key: variant.get(key) for key in varies_by},
"url": variant.get("url") if is_absolute_url(variant.get("url")) else None,
"image": images,
"offers": offers,
}
def build_product_schema(source, now=None):
now = now or datetime.now(timezone.utc)
if not passes_page_gates(source):
return None
kind = choose_product_type(source)
entity, listing = kind["entity"], kind["listing"]
name = str(source.get("name") or "").strip()
images = [u for u in source.get("images") or [] if is_absolute_url(u)]
if not name:
return None
aggregate_rating = build_rating(source.get("rating"), "AggregateRating")
reviews = [r for r in (build_editorial_review(x, listing) for x in source.get("reviews") or []) if r]
shared = {
"@context": "https://schema.org",
"@type": entity,
"name": name,
"image": images,
"description": source.get("description"),
"brand": {"@type": "Brand", "name": source["brand"]} if source.get("brand") else None,
"aggregateRating": aggregate_rating,
"review": reviews,
}
if entity == "ProductGroup":
group_id = source.get("groupId") if is_sku(source.get("groupId")) else None
varies_by = [v for v in source.get("variesBy") or [] if v in VARIES_BY]
ids, variants = set(), []
for v in (build_variant(x, listing, source, group_id, varies_by, now) for x in source["variants"]):
vid = v and (v["sku"] or v["gtin"])
if not v or vid in ids:
continue # each variant needs a unique ID
ids.add(vid)
variants.append(v)
if not group_id or not variants:
return None
return compact({
**shared,
"productGroupID": group_id,
"url": None if source.get("variantPages") else source.get("url"), # single-page sites only
"variesBy": [f"https://schema.org/{v}" for v in varies_by],
"hasVariant": variants,
})
# Merchant listing requires image and a valid Offer; product snippet requires review, rating or offers.
offers = build_offer(source.get("offer"), listing, source, now)
if listing == "merchant" and (not images or (offers or {}).get("@type") != "Offer"):
return None
if listing == "snippet" and not offers and not aggregate_rating and not reviews:
return None
return compact({
**shared,
"sku": source["sku"] if is_sku(source.get("sku")) else None,
"mpn": source.get("mpn"),
"gtin": str(source["gtin"]) if is_gtin(source.get("gtin")) else None,
"offers": offers,
})
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Northwind Trail 2 Running Shoe",
"image": [
"https://www.example.com/img/northwind-trail-2.jpg"
],
"description": "A cushioned trail running shoe with a rock plate and a 6 mm drop.",
"brand": {
"@type": "Brand",
"name": "Northwind"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.4,
"bestRating": 5,
"worstRating": 1,
"reviewCount": 57
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Jordan Lee"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 4.5,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-09-22",
"positiveNotes": {
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Stable on loose descents"
},
{
"@type": "ListItem",
"position": 2,
"name": "Grippy outsole in the wet"
}
]
},
"negativeNotes": {
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Runs half a size small"
}
]
}
}
],
"sku": "NW-TRAIL2",
"mpn": "TR2-2026",
"gtin": "00812345678905"
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Product snippet 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: Include one of review, aggregateRating or offers.
Required, one of: In aggregateRating: at least one of ratingCount or reviewCount is required.
Required, one of: Include offers.price or offers.priceSpecification.price.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The name of the product. |
| review | One of | Review, can repeat | A nested Review of the product. 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. |
| review.positiveNotes | Recommended | ItemList | An optional nested list of positive statements (pros) about the product. Editorial reviews only. |
| review.negativeNotes | Recommended | ItemList | An optional nested list of negative statements (cons) about the product. Editorial reviews only. |
| aggregateRating | One of | AggregateRating | A nested aggregate rating of the product. 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. |
| offers | One of | Offer or AggregateOffer, can repeat | A nested Offer or AggregateOffer to sell the product. Use Offer (not AggregateOffer) to be eligible for the price drop enhancement. |
| offers.price | One of | Number or Text | The offer price of the product. Use 0 for free products. Or use priceSpecification.price. |
| offers.priceSpecification | One of | UnitPriceSpecification or PriceSpecification, can repeat | Use if offers.price is not provided. |
| offers.priceCurrency | Recommended | Text | Recommended for product snippets to help Google determine the currency accurately, but required for merchant listing experiences. |
| offers.availability | Recommended | Enum | The single most appropriate ItemAvailability value. One of: BackOrder, Discontinued, InStock, InStoreOnly, LimitedAvailability, OnlineOnly, and 4 more. |
| offers.priceValidUntil | Recommended | Date | ISO 8601. Your product snippet may not display if it indicates a past date. |
| offers.url | Recommended | URL | The URL of the offer. |
| offers.itemCondition | Recommended | Enum | The condition of the product, for example NewCondition or UsedCondition. One of: NewCondition, RefurbishedCondition, UsedCondition. |
| brand | Recommended | Brand or Organization | The brand of the product. |
| brand.name | Recommended | Text | |
| description | Recommended | Text | The product description. |
| image | Recommended | URL or ImageObject, can repeat | The URL of a product photo. |
| sku | Recommended | Text | The merchant-specific identifier for the product. |
| mpn | Recommended | Text | The manufacturer part number. |
| gtin | Recommended | Text | If applicable. Or use gtin8, gtin12, gtin13, gtin14. |