Merchant Listing Schema Framework
Use conditional-logic schema generation to keep Merchant Listing markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralMerchant Listing documentationUse on product pages where a shopper can buy the product: only those pages are eligible for merchant listing experiences, and the required merchant listing properties also make the page eligible for product snippets. Merchant listings go further than product snippets: the price must be greater than zero, offers must be a single Offer, and shipping, returns, member prices and unit pricing can be described. Editorial review pages that don't sell the product belong in the Product snippet framework.
Key Implementation Documentation Highlights
- Required properties: name, image, offers. offers must be an Offer (AggregateOffer is not supported for merchant listings) with price or priceSpecification.price, and priceCurrency or priceSpecification.priceCurrency.
- Price rules: the active price must be greater than zero (product snippets accept 0, merchant listings don't). Use a JSON number or a string with a dot as the decimal separator, and a three-letter ISO 4217 currency code. If offers.price and priceSpecification both encode an active price, Google uses offers.price. Sell in several currencies from distinct URLs, one per currency.
- Recommended Offer properties: availability (one ItemAvailability value such as InStock, OutOfStock or PreOrder), itemCondition (NewCondition, RefurbishedCondition or UsedCondition), a single url, priceValidUntil (a past date can stop the listing from showing), validFrom and validThrough for sale windows (start no later than end), shippingDetails and hasMerchantReturnPolicy.
- Strikethrough and member prices go in priceSpecification as UnitPriceSpecification entries: priceType StrikethroughPrice (ListPrice is the former name) marks the original price and should be higher than the active price; validForMemberTier links a price to a loyalty tier by @id (the tier is defined under Organization hasMemberProgram), optionally with a whole number membershipPointsEarned. Never put priceType and validForMemberTier on the same entry; the active price has neither.
- Unit pricing: referenceQuantity on a UnitPriceSpecification (value, unitCode such as ML, and valueReference for the base measure). It matters most in the EU, New Zealand and Australia for products sold by volume, length or weight.
- Shipping: each OfferShippingDetails entry carries a shippingRate (MonetaryAmount with currency and value or maxValue; value 0 for free shipping), a shippingDestination DefinedRegion (ISO 3166-1 alpha-2 addressCountry; optional addressRegion as a 2 or 3 character ISO 3166-2 code without the country prefix, postalCode or postalCodeRange) and a deliveryTime (handlingTime and transitTime in days with unitCode DAY or d, cutoffTime with a UTC offset, businessDays). doesNotShip and shippingOrigin are also recommended.
- Returns: a MerchantReturnPolicy needs applicableCountry (ISO 3166-1 alpha-2, up to 50) and returnPolicyCategory. MerchantReturnFiniteReturnWindow requires merchantReturnDays; returnFees ReturnShippingFees requires returnShippingFeesAmount. returnMethod, returnFees, returnPolicyCountry and merchantReturnLink are recommended. Google recommends a global return and shipping policy under Organization instead of repeating them per Offer: see the Organization framework.
- Recommended product properties: description (strongly recommended), brand (one), gtin (or gtin8, gtin12, gtin13, gtin14), mpn, sku, color, size, material, pattern, audience (PeopleAudience), category (text or a Google Product Category CategoryCode), aggregateRating, review, hasCertification (up to 10), hasAdultConsideration, subjectOf (a glTF 3D model), plus inProductGroupWithID and isVariantOf for variants.
- Identifier and value rules: gtin is numeric with 8, 12, 13 or 14 digits (the URL form is not supported); sku has no whitespace; availability, itemCondition, brand, size, inProductGroupWithID, subjectOf and offers.url take one value each; hasAdultConsideration only accepts SexualContentConsideration.
- Images must be crawlable, indexable and represent the product, in a format Google Images supports; Google recommends at least 50K pixels (width x height) and multiple high resolution images.
- Mark up pages that focus on a single product (or variants of one product), not category or listing pages. Prefer markup in the initial HTML: JavaScript-generated markup can make Shopping crawls less frequent and less reliable for fast-changing price and availability. Pages promoting widely prohibited or regulated goods are not allowed.
- Variants of one product use a ProductGroup with hasVariant, where each variant Offer follows these rules: see the Product Variants framework.
- 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: "Ridgeline 3L Waterproof Rain Jacket, Women's",
image: "https://www.example.com/img/ridgeline-womens-1x1.jpg",
price: "179.00",
currency: "USD",
sku: "TH-RIDGE-W-SPR-M"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Product",
"name": pageData.name,
"image": [pageData.image],
"sku": pageData.sku,
"offers": {
"@type": "Offer",
"price": pageData.price,
"priceCurrency": pageData.currency,
"availability": "https://schema.org/InStock"
}
};
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Ridgeline 3L Waterproof Rain Jacket, Women's",
"image": [
"https://www.example.com/img/ridgeline-womens-1x1.jpg"
],
"sku": "TH-RIDGE-W-SPR-M",
"offers": {
"@type": "Offer",
"price": "179.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
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 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", shipping_fee: "ReturnShippingFees" };
const RETURN_METHOD = { kiosk: "ReturnAtKiosk", mail: "ReturnByMail", in_store: "ReturnInStore" };
const GENDERS = ["male", "female", "unisex"];
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
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 isRegionCode = (value) => typeof value === "string" && /^[A-Z0-9]{1,3}$/.test(value);
const isGtin = (value) => /^(\d{8}|\d{12,14})$/.test(String(value ?? "")); // numeric only, never a URL
const isSku = (value) => typeof value === "string" && /^\S+$/.test(value); // no whitespace
const isIsoTime = (value) => typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?(Z|[+-]\d{2}:\d{2})$/.test(value);
const schemaEnum = (value, allowed) => (allowed.includes(value) ? S + value : null);
// Comparable instant in ms; date-only and offset-less values are read as UTC.
const toInstant = (iso) =>
Date.parse(/(Z|[+-]\d{2}:\d{2})$/.test(iso) ? iso : iso.length === 10 ? `${iso}T00:00:00Z` : `${iso}Z`);
const wholeNumber = (value) => (Number.isInteger(value) && value >= 0 ? value : null);
// 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)
};
}
function buildRegion(country, regions = [], postalCodes = []) {
if (!isCountryCode(country)) return null;
return {
"@type": "DefinedRegion",
addressCountry: country,
addressRegion: regions.filter(isRegionCode), // ISO 3166-2 subdivision without the country prefix
postalCode: postalCodes.filter((p) => typeof p === "string" && p.trim())
};
}
const daysRange = ([min, max] = []) =>
wholeNumber(min) !== null && wholeNumber(max) !== null && min <= max
? { "@type": "QuantitativeValue", minValue: min, maxValue: max, unitCode: "DAY" }
: null;
// One OfferShippingDetails per destination: shippingRate, shippingDestination and deliveryTime are required in it.
function buildShipping(s, origin) {
const destination = buildRegion(s?.country, s?.regions, s?.postalCodes);
const rate = toNumber(s?.rate);
const handlingTime = daysRange(s?.handlingDays);
const transitTime = daysRange(s?.transitDays);
if (!destination || rate === null || rate < 0 || !isCurrencyCode(s.currency)) return null; // value 0 means free shipping
if (!handlingTime && !transitTime) return null;
const days = (s.businessDays ?? []).filter((d) => WEEKDAYS.includes(d));
return {
"@type": "OfferShippingDetails",
shippingRate: { "@type": "MonetaryAmount", value: rate, currency: s.currency },
shippingDestination: destination,
shippingOrigin: buildRegion(origin?.country, origin?.regions),
deliveryTime: {
"@type": "ShippingDeliveryTime",
handlingTime,
transitTime,
cutoffTime: isIsoTime(s.cutoffTime) ? s.cutoffTime : null,
businessDays: days.length ? { "@type": "OpeningHoursSpecification", dayOfWeek: days.map((d) => S + d) } : null
}
};
}
// Finite windows need merchantReturnDays; ReturnShippingFees needs returnShippingFeesAmount.
function buildReturnPolicy(r) {
const countries = (r?.countries ?? []).filter(isCountryCode).slice(0, 50);
const category = RETURN_CATEGORY[r?.category];
if (!category || countries.length === 0) return null;
const finite = category === "MerchantReturnFiniteReturnWindow";
const days = wholeNumber(r.days);
if (finite && !(days > 0)) return null;
const fee = toNumber(r.feeAmount);
const chargesShipping = r.fees === "shipping_fee";
if (chargesShipping && !(fee !== null && fee >= 0 && isCurrencyCode(r.feeCurrency))) return null;
return {
"@type": "MerchantReturnPolicy",
applicableCountry: countries,
returnPolicyCountry: (r.returnToCountries ?? []).filter(isCountryCode),
returnPolicyCategory: S + category,
merchantReturnDays: finite ? days : null,
returnMethod: (r.methods ?? []).map((m) => RETURN_METHOD[m]).filter(Boolean).map((m) => S + m),
returnFees: RETURN_FEES[r.fees] ? S + RETURN_FEES[r.fees] : null,
returnShippingFeesAmount: chargesShipping ? { "@type": "MonetaryAmount", value: fee, currency: r.feeCurrency } : null,
merchantReturnLink: isAbsoluteUrl(r.link) ? r.link : null
};
}
// Extra UnitPriceSpecification entries: strikethrough, member tier and unit pricing.
// An active price has neither priceType nor validForMemberTier; never combine the two.
function buildPriceSpecifications(offer, price, currency) {
const specs = [];
const was = toNumber(offer.strikethroughPrice);
if (was !== null && was > price) {
specs.push({ "@type": "UnitPriceSpecification", priceType: S + "StrikethroughPrice", price: was, priceCurrency: currency });
}
const member = offer.memberPrice;
const memberPrice = toNumber(member?.price);
if (memberPrice > 0 && isAbsoluteUrl(member.tierId)) {
specs.push({
"@type": "UnitPriceSpecification",
price: memberPrice,
priceCurrency: currency,
validForMemberTier: { "@id": member.tierId }, // tier defined under Organization.hasMemberProgram
membershipPointsEarned: wholeNumber(member.pointsEarned)
});
}
const unit = offer.unitPricing;
const quantity = toNumber(unit?.value);
const base = toNumber(unit?.baseValue);
if (quantity > 0 && /^[A-Z0-9]{2,3}$/.test(unit.unitCode ?? "")) {
specs.push({
"@type": "UnitPriceSpecification",
price,
priceCurrency: currency,
referenceQuantity: {
"@type": "QuantitativeValue",
value: quantity,
unitCode: unit.unitCode, // for example ML
valueReference: base > 0 ? { "@type": "QuantitativeValue", value: base, unitCode: unit.baseUnitCode ?? unit.unitCode } : null
}
});
}
return specs;
}
// A single Offer (AggregateOffer is not supported) with price > 0 and an ISO 4217 currency.
function buildOffer(offer, source, now) {
if (!offer || offer.lowPrice !== undefined) return null;
const price = toNumber(offer.price);
const currency = offer.currency;
if (!(price > 0) || !isCurrencyCode(currency)) return null;
// Sale window: validFrom must not be later than validThrough or priceValidUntil.
const validUntil = toIsoDate(offer.priceValidUntil);
const priceValidUntil = validUntil && toInstant(`${validUntil}T23:59:59`) >= now.getTime() ? validUntil : null; // a past date can hide the listing
let validFrom = toIsoDateTime(offer.validFrom);
let validThrough = toIsoDateTime(offer.validThrough);
const end = validThrough ? toInstant(validThrough) : priceValidUntil ? toInstant(`${priceValidUntil}T23:59:59`) : null;
if (validFrom && end !== null && toInstant(validFrom) > end) validFrom = validThrough = null;
return {
"@type": "Offer",
url: isAbsoluteUrl(offer.url) ? offer.url : null,
price,
priceCurrency: currency,
priceSpecification: buildPriceSpecifications(offer, price, currency),
priceValidUntil,
validFrom,
validThrough,
availability: schemaEnum(offer.availability, AVAILABILITY),
itemCondition: schemaEnum(offer.condition, CONDITIONS),
shippingDetails: (source.shipping ?? []).map((s) => buildShipping(s, source.shipsFrom)),
hasMerchantReturnPolicy: buildReturnPolicy(source.returns)
};
}
function buildAudience(a) {
if (!a) return null;
const min = toNumber(a.minAge);
const max = toNumber(a.maxAge);
return {
"@type": "PeopleAudience",
suggestedGender: GENDERS.includes(a.gender) ? a.gender : null,
suggestedMinAge: min !== null && min >= 0 ? min : null,
suggestedMaxAge: max !== null && max >= 0 && (min === null || max >= min) ? max : null
};
}
function buildMerchantListingSchema(source, now = new Date()) {
if (!passesPageGates(source)) return null;
if (source.purchasable !== true) return null; // no checkout on this page: use the Product snippet framework
const name = String(source.name ?? "").trim();
const images = (source.images ?? []).filter(isAbsoluteUrl);
const offers = buildOffer(source.offer, source, now);
if (!name || images.length === 0 || !offers) return null; // name, image and offers are required
return compact({
"@context": "https://schema.org",
"@type": "Product",
name,
image: images,
description: source.description,
sku: isSku(source.sku) ? source.sku : null,
mpn: source.mpn,
gtin: isGtin(source.gtin) ? String(source.gtin) : null,
brand: source.brand ? { "@type": "Brand", name: source.brand } : null,
category: source.category,
color: source.color,
material: source.material,
pattern: source.pattern,
size: source.size,
audience: buildAudience(source.audience),
aggregateRating: buildRating(source.rating, "AggregateRating"),
review: (source.reviews ?? []).map(buildReview).filter(Boolean),
offers
});
}
// Usage:
// const source = {
// url: "https://www.example.com/shop/ridgeline-rain-jacket-womens",
// canonicalUrl: "https://www.example.com/shop/ridgeline-rain-jacket-womens",
// indexable: true,
// contentVisible: true,
// purchasable: true,
// name: "Ridgeline 3L Waterproof Rain Jacket, Women's",
// description: "Three-layer waterproof shell with pit zips, a helmet-compatible hood and taped seams.",
// images: [
// "https://www.example.com/img/ridgeline-womens-1x1.jpg",
// "https://www.example.com/img/ridgeline-womens-4x3.jpg",
// "https://www.example.com/img/ridgeline-womens-16x9.jpg"
// ],
// brand: "Trailhead Outfitters",
// sku: "TH-RIDGE-W-SPR-M",
// mpn: "RDG3L-W-26",
// gtin: "00812345670142",
// category: "Apparel & Accessories > Clothing > Outerwear > Coats & Jackets",
// color: "Spruce",
// material: "Recycled nylon",
// pattern: "Solid",
// size: "M",
// audience: { gender: "female", minAge: 13 },
// rating: { ratingValue: 4.6, reviewCount: 128 },
// reviews: [{ authorName: "Priya Shah", rating: { ratingValue: 5 }, datePublished: "2026-09-14" }],
// offer: {
// url: "https://www.example.com/shop/ridgeline-rain-jacket-womens",
// price: 179,
// currency: "USD",
// strikethroughPrice: 229,
// memberPrice: { price: 161, tierId: "https://www.example.com/rewards#tier-gold", pointsEarned: 179 },
// validFrom: "2026-10-01T00:00:00-04:00",
// validThrough: "2026-10-31T23:59:59-04:00",
// priceValidUntil: "2026-10-31",
// availability: "InStock",
// condition: "NewCondition"
// },
// shipsFrom: { country: "US", regions: ["MA"] },
// shipping: [
// {
// country: "US",
// rate: 0,
// currency: "USD",
// handlingDays: [0, 1],
// transitDays: [2, 5],
// cutoffTime: "14:00:00-04:00",
// businessDays: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
// }
// ],
// returns: {
// countries: ["US"],
// returnToCountries: ["US"],
// category: "finite",
// days: 30,
// methods: ["mail", "in_store"],
// fees: "shipping_fee",
// feeAmount: 6.95,
// feeCurrency: "USD",
// link: "https://www.example.com/help/returns"
// }
// };
// const jsonLd = buildMerchantListingSchema(source);
import re
from datetime import date, datetime
from datetime import timezone
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/"
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", "shipping_fee": "ReturnShippingFees"}
RETURN_METHOD = {"kiosk": "ReturnAtKiosk", "mail": "ReturnByMail", "in_store": "ReturnInStore"}
GENDERS = ["male", "female", "unisex"]
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
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_region_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z0-9]{1,3}", value) is not None
def is_gtin(value): # numeric only, never a URL
return re.fullmatch(r"\d{8}|\d{12,14}", str(value if value is not None else "")) is not None
def is_sku(value): # no whitespace
return isinstance(value, str) and re.fullmatch(r"\S+", value) is not None
def is_iso_time(value):
return isinstance(value, str) and re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?(Z|[+-]\d{2}:\d{2})", value) is not None
def schema_enum(value, allowed):
return S + value if value in allowed else None
# Comparable instant; date-only and offset-less values are read as UTC.
def to_instant(iso):
if len(iso) == 10:
iso += "T00:00:00"
moment = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def whole_number(value):
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 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")),
}
def build_region(country, regions=None, postal_codes=None):
if not is_country_code(country):
return None
return {
"@type": "DefinedRegion",
"addressCountry": country,
"addressRegion": [r for r in (regions or []) if is_region_code(r)], # ISO 3166-2 subdivision without the country prefix
"postalCode": [p for p in (postal_codes or []) if isinstance(p, str) and p.strip()],
}
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
# One OfferShippingDetails per destination: shippingRate, shippingDestination and deliveryTime are required in it.
def build_shipping(s, origin):
s = s or {}
origin = origin or {}
destination = build_region(s.get("country"), s.get("regions"), s.get("postalCodes"))
rate = to_number(s.get("rate"))
handling_time = days_range(s.get("handlingDays"))
transit_time = days_range(s.get("transitDays"))
if not destination or rate is None or rate < 0 or not is_currency_code(s.get("currency")):
return None # value 0 means free shipping
if not handling_time and not transit_time:
return None
days = [d for d in (s.get("businessDays") or []) if d in WEEKDAYS]
return {
"@type": "OfferShippingDetails",
"shippingRate": {"@type": "MonetaryAmount", "value": rate, "currency": s["currency"]},
"shippingDestination": destination,
"shippingOrigin": build_region(origin.get("country"), origin.get("regions")),
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": handling_time,
"transitTime": transit_time,
"cutoffTime": s["cutoffTime"] if is_iso_time(s.get("cutoffTime")) else None,
"businessDays": {"@type": "OpeningHoursSpecification", "dayOfWeek": [S + d for d in days]} if days else None,
},
}
# Finite windows need merchantReturnDays; ReturnShippingFees needs returnShippingFeesAmount.
def build_return_policy(r):
r = r or {}
countries = [c for c in (r.get("countries") or []) if is_country_code(c)][:50]
category = RETURN_CATEGORY.get(r.get("category"))
if not category or not countries:
return None
finite = category == "MerchantReturnFiniteReturnWindow"
days = whole_number(r.get("days"))
if finite and not (days is not None and days > 0):
return None
fee = to_number(r.get("feeAmount"))
charges_shipping = r.get("fees") == "shipping_fee"
if charges_shipping and not (fee is not None and fee >= 0 and is_currency_code(r.get("feeCurrency"))):
return None
return {
"@type": "MerchantReturnPolicy",
"applicableCountry": countries,
"returnPolicyCountry": [c for c in (r.get("returnToCountries") or []) if is_country_code(c)],
"returnPolicyCategory": S + category,
"merchantReturnDays": days if finite else None,
"returnMethod": [S + RETURN_METHOD[m] for m in (r.get("methods") or []) if m in RETURN_METHOD],
"returnFees": S + RETURN_FEES[r["fees"]] if r.get("fees") in RETURN_FEES else None,
"returnShippingFeesAmount": {"@type": "MonetaryAmount", "value": fee, "currency": r["feeCurrency"]} if charges_shipping else None,
"merchantReturnLink": r["link"] if is_absolute_url(r.get("link")) else None,
}
# Extra UnitPriceSpecification entries: strikethrough, member tier and unit pricing.
# An active price has neither priceType nor validForMemberTier; never combine the two.
def build_price_specifications(offer, price, currency):
specs = []
was = to_number(offer.get("strikethroughPrice"))
if was is not None and was > price:
specs.append({"@type": "UnitPriceSpecification", "priceType": S + "StrikethroughPrice", "price": was, "priceCurrency": currency})
member = offer.get("memberPrice") or {}
member_price = to_number(member.get("price"))
if member_price is not None and member_price > 0 and is_absolute_url(member.get("tierId")):
specs.append({
"@type": "UnitPriceSpecification",
"price": member_price,
"priceCurrency": currency,
"validForMemberTier": {"@id": member["tierId"]}, # tier defined under Organization.hasMemberProgram
"membershipPointsEarned": whole_number(member.get("pointsEarned")),
})
unit = offer.get("unitPricing") or {}
quantity = to_number(unit.get("value"))
base = to_number(unit.get("baseValue"))
if quantity is not None and quantity > 0 and re.fullmatch(r"[A-Z0-9]{2,3}", unit.get("unitCode") or ""):
specs.append({
"@type": "UnitPriceSpecification",
"price": price,
"priceCurrency": currency,
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": quantity,
"unitCode": unit["unitCode"], # for example ML
"valueReference": {"@type": "QuantitativeValue", "value": base, "unitCode": unit.get("baseUnitCode") or unit["unitCode"]}
if base is not None and base > 0 else None,
},
})
return specs
# A single Offer (AggregateOffer is not supported) with price > 0 and an ISO 4217 currency.
def build_offer(offer, source, now):
if not offer or "lowPrice" in offer:
return None
price = to_number(offer.get("price"))
currency = offer.get("currency")
if not (price is not None and price > 0) or not is_currency_code(currency):
return None
# Sale window: validFrom must not be later than validThrough or priceValidUntil.
valid_until = to_iso_date(offer.get("priceValidUntil"))
price_valid_until = valid_until if valid_until and to_instant(valid_until + "T23:59:59") >= now else None # a past date can hide the listing
valid_from = to_iso_date_time(offer.get("validFrom"))
valid_through = to_iso_date_time(offer.get("validThrough"))
end = to_instant(valid_through) if valid_through else to_instant(price_valid_until + "T23:59:59") if price_valid_until else None
if valid_from and end is not None and to_instant(valid_from) > end:
valid_from = valid_through = None
return {
"@type": "Offer",
"url": offer["url"] if is_absolute_url(offer.get("url")) else None,
"price": price,
"priceCurrency": currency,
"priceSpecification": build_price_specifications(offer, price, currency),
"priceValidUntil": price_valid_until,
"validFrom": valid_from,
"validThrough": valid_through,
"availability": schema_enum(offer.get("availability"), AVAILABILITY),
"itemCondition": schema_enum(offer.get("condition"), CONDITIONS),
"shippingDetails": [build_shipping(s, source.get("shipsFrom")) for s in (source.get("shipping") or [])],
"hasMerchantReturnPolicy": build_return_policy(source.get("returns")),
}
def build_audience(a):
if not a:
return None
lo = to_number(a.get("minAge"))
hi = to_number(a.get("maxAge"))
return {
"@type": "PeopleAudience",
"suggestedGender": a.get("gender") if a.get("gender") in GENDERS else None,
"suggestedMinAge": lo if lo is not None and lo >= 0 else None,
"suggestedMaxAge": hi if hi is not None and hi >= 0 and (lo is None or hi >= lo) else None,
}
def build_merchant_listing_schema(source, now=None):
now = now or datetime.now(timezone.utc)
if not passes_page_gates(source):
return None
if source.get("purchasable") is not True:
return None # no checkout on this page: use the Product snippet framework
name = str(source.get("name") or "").strip()
images = [i for i in (source.get("images") or []) if is_absolute_url(i)]
offers = build_offer(source.get("offer"), source, now)
if not name or not images or not offers:
return None # name, image and offers are required
return compact({
"@context": "https://schema.org",
"@type": "Product",
"name": name,
"image": images,
"description": source.get("description"),
"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,
"brand": {"@type": "Brand", "name": source["brand"]} if source.get("brand") else None,
"category": source.get("category"),
"color": source.get("color"),
"material": source.get("material"),
"pattern": source.get("pattern"),
"size": source.get("size"),
"audience": build_audience(source.get("audience")),
"aggregateRating": build_rating(source.get("rating"), "AggregateRating"),
"review": [r for r in (build_review(x) for x in (source.get("reviews") or [])) if r],
"offers": offers,
})
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Ridgeline 3L Waterproof Rain Jacket, Women's",
"image": [
"https://www.example.com/img/ridgeline-womens-1x1.jpg",
"https://www.example.com/img/ridgeline-womens-4x3.jpg",
"https://www.example.com/img/ridgeline-womens-16x9.jpg"
],
"description": "Three-layer waterproof shell with pit zips, a helmet-compatible hood and taped seams.",
"sku": "TH-RIDGE-W-SPR-M",
"mpn": "RDG3L-W-26",
"gtin": "00812345670142",
"brand": {
"@type": "Brand",
"name": "Trailhead Outfitters"
},
"category": "Apparel & Accessories > Clothing > Outerwear > Coats & Jackets",
"color": "Spruce",
"material": "Recycled nylon",
"pattern": "Solid",
"size": "M",
"audience": {
"@type": "PeopleAudience",
"suggestedGender": "female",
"suggestedMinAge": 13
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.6,
"bestRating": 5,
"worstRating": 1,
"reviewCount": 128
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Priya Shah"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-09-14"
}
],
"offers": {
"@type": "Offer",
"url": "https://www.example.com/shop/ridgeline-rain-jacket-womens",
"price": 179,
"priceCurrency": "USD",
"priceSpecification": [
{
"@type": "UnitPriceSpecification",
"priceType": "https://schema.org/StrikethroughPrice",
"price": 229,
"priceCurrency": "USD"
},
{
"@type": "UnitPriceSpecification",
"price": 161,
"priceCurrency": "USD",
"validForMemberTier": {
"@id": "https://www.example.com/rewards#tier-gold"
},
"membershipPointsEarned": 179
}
],
"priceValidUntil": "2026-10-31",
"validFrom": "2026-10-01T00:00:00-04:00",
"validThrough": "2026-10-31T23:59:59-04:00",
"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"
},
"shippingOrigin": {
"@type": "DefinedRegion",
"addressCountry": "US",
"addressRegion": [
"MA"
]
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
},
"cutoffTime": "14:00:00-04:00",
"businessDays": {
"@type": "OpeningHoursSpecification",
"dayOfWeek": [
"https://schema.org/Monday",
"https://schema.org/Tuesday",
"https://schema.org/Wednesday",
"https://schema.org/Thursday",
"https://schema.org/Friday"
]
}
}
}
],
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": [
"US"
],
"returnPolicyCountry": [
"US"
],
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": [
"https://schema.org/ReturnByMail",
"https://schema.org/ReturnInStore"
],
"returnFees": "https://schema.org/ReturnShippingFees",
"returnShippingFeesAmount": {
"@type": "MonetaryAmount",
"value": 6.95,
"currency": "USD"
},
"merchantReturnLink": "https://www.example.com/help/returns"
}
}
}
Why Conditional Logic Is Better Than Static Templates
- Refuses to emit merchant listing markup when the page can't take an order, the price is zero or the currency is not ISO 4217, instead of shipping invalid offers.
- Builds strikethrough, member and unit prices only when the data supports them, so a stale sale price or a member price without a tier never leaks into markup.
- Drops a past priceValidUntil and a sale window whose start is after its end, which would otherwise hide the listing or contradict the visible price.
- Keeps shipping and return policies consistent with their conditional rules (finite windows need days, return shipping fees need an amount) from one code path.
Property Reference
What Google's Merchant listing 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 offers.price or offers.priceSpecification.price.
Required, one of: Include offers.priceCurrency or offers.priceSpecification.priceCurrency.
Required, one of: In aggregateRating: at least one of ratingCount or reviewCount is required.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The name of the product. |
| image | Required | URL or ImageObject, can repeat | The URL of a product photo. Prefer multiple high resolution images. Aspect ratios: 16x9, 4x3, 1x1. |
| offers | Required | Offer, can repeat | A nested Offer to sell the product. AggregateOffer is not supported for merchant listings. |
| offers.price | One of | Number or Text | The current, active offer price. Must be greater than zero. Or use priceSpecification.price. |
| offers.priceCurrency | One of | Text | In three-letter ISO 4217 format. Or use priceSpecification.priceCurrency. |
| offers.priceSpecification | One of | UnitPriceSpecification, can repeat | For complex pricing (strikethrough, member, unit prices). |
| offers.availability | Recommended | Enum | Specify only one ItemAvailability value. One of: BackOrder, Discontinued, InStock, InStoreOnly, LimitedAvailability, OnlineOnly, and 4 more. |
| offers.itemCondition | Recommended | Enum | Specify only one value. One of: NewCondition, RefurbishedCondition, UsedCondition. |
| offers.hasMerchantReturnPolicy | Recommended | MerchantReturnPolicy, can repeat | Or, preferably, a global return policy under Organization. |
| offers.shippingDetails | Recommended | OfferShippingDetails, can repeat | Or, preferably, a shipping policy under Organization. |
| offers.priceValidUntil | Recommended | Date | ISO 8601. Your listing may not display if it indicates a past date. |
| offers.url | Recommended | URL | The product page URL where the buyer can purchase. Can be omitted; don't provide multiple URLs. |
| offers.validFrom | Recommended | DateTime or Date | ISO 8601 start of a sale price. |
| offers.validThrough | Recommended | DateTime or Date | ISO 8601 end of a sale price. |
| aggregateRating | Recommended | AggregateRating | 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. |
| audience | Recommended | PeopleAudience | The suggested gender and age group. Only PeopleAudience is supported. |
| audience.suggestedGender | Recommended | Text or Enum | Male, female or unisex. One of: male, female, unisex, Male, Female. |
| audience.suggestedMinAge | Recommended | Number | In years. |
| audience.suggestedMaxAge | Recommended | Number | In years. |
| brand | Recommended | Brand or Organization | The brand of the product. Specify at most one. |
| brand.name | Recommended | Text | Maximum one. |
| category | Recommended | Text or CategoryCode, can repeat | A custom category or Google Product Category (GPC) as CategoryCode. |
| color | Recommended | Text | For example red or yellow/sky blue. |
| description | Recommended | Text | Strongly recommended. |
| gtin | Recommended | Text | . Numeric form only; URL form is not supported. Or use the most specific gtin8, gtin12, gtin13, gtin14. |
| gtin8 | Recommended | Text | Use if applicable. |
| gtin12 | Recommended | Text | Use if applicable. |
| gtin13 | Recommended | Text | Use if applicable. |
| gtin14 | Recommended | Text | Use if applicable. |
| isbn | Recommended | Text | Only valid on Book co-typed with Product. ISBN-13 recommended. |
| hasAdultConsideration | Recommended | Enum | For adult products. One of: SexualContentConsideration. |
| hasCertification | Recommended | Certification, can repeat | Energy efficiency or other certifications. |
| hasCertification.name | Required | Text | The certification type, for example EPREL (EU energy label) or Vehicle_CO2_Class. |
| hasCertification.issuedBy | Required | Organization | The organization that issued the certification. |
| hasCertification.certificationIdentification | Recommended | Text | The certification identifier, for example the EPREL registration number. |
| hasCertification.certificationRating | Recommended | Rating | With ratingValue, for example the energy or CO2 class (D). |
| hasEnergyConsumptionDetails | Recommended | EnergyConsumptionDetails | Deprecated: use hasCertification for EU energy labels instead. |
| hasEnergyConsumptionDetails.hasEnergyEfficiencyCategory | Required | Enum | The product's energy efficiency class. One of: EUEnergyEfficiencyCategoryA3Plus, EUEnergyEfficiencyCategoryA2Plus, EUEnergyEfficiencyCategoryA1Plus, EUEnergyEfficiencyCategoryA, EUEnergyEfficiencyCategoryB,... |
| hasEnergyConsumptionDetails.energyEfficiencyScaleMin | Recommended | Enum | Least efficient class on the label scale. |
| hasEnergyConsumptionDetails.energyEfficiencyScaleMax | Recommended | Enum | Most efficient class on the label scale. |
| inProductGroupWithID | Recommended | Text | The product group ID this variant belongs to. Maximum one. |
| isVariantOf | Recommended | ProductGroup | The ProductGroup this product is a variant of. |
| material | Recommended | Text | For example Leather or Cotton/Polyester. |
| mpn | Recommended | Text | The manufacturer part number. |
| pattern | Recommended | Text | For example polka dots. |
| review | Recommended | Review, can repeat | The reviewer's name must be a valid Person or Team. |
| 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. |
| size | Recommended | Text or SizeSpecification | For example XL or medium. Specify at most one value. |
| size.name | Recommended | Text | A human readable size value, for example XL or medium. |
| size.sizeGroup | Recommended | Enum or Text, can repeat | The suggested size group for the product. One of: WearableSizeGroupBig, WearableSizeGroupMaternity, WearableSizeGroupPetite, WearableSizeGroupPlus, WearableSizeGroupRegular, WearableSizeGroupTall. |
| size.sizeSystem | Recommended | Enum or Text | The size system used. One of: WearableSizeSystemAU, WearableSizeSystemBR, WearableSizeSystemCN, WearableSizeSystemDE, WearableSizeSystemEurope, WearableSizeSystemFR, and 5 more. |
| sku | Recommended | Text | Merchant-specific identifier. Unicode characters valid for interchange, no whitespace. |
| subjectOf | Recommended | 3DModel | A 3D model of the product in glTF format. Maximum one. |
| subjectOf.encoding | Required | MediaObject | With contentUrl to a .gltf or .glb file. |