Product structured data: two rule sets, one decision, and the price that must be above zero

Product structured data starts with one decision: can the visitor buy on this page? Google publishes two requirement sets — merchant listings for pages that sell, product snippets for pages that review or compare — and what is optional in one is mandatory in the other. A comparison table, two pasteable JSON-LD blocks, an audit command, and the three failures that pass a validator.

Crawling & Indexing9 min read1118 views
Product structured data: two rule sets, one decision, and the price that must be above zero

Product structured data is one decision before any code: is this a page where someone can buy the thing, or a page that talks about it? Google publishes two separate requirement sets — merchant listings for the first, product snippets for the second — and the properties that are optional in one are mandatory in the other. Below: how to tell them apart, two blocks you can paste, an audit command, and the three ways the markup goes wrong on real stores.

Read this after schema markup for AI search, which settles which types Google still shows at all, and before you open the Rich Results Test — its Product tab reports against both requirement sets, and the warnings only make sense once you know which one you are aiming for.

Which product structured data does your page need?

Google's own split is by what the visitor can do on the page, not by what you sell. "Product snippets: For product pages where people can't directly purchase the product … Merchant listings: For pages where customers can purchase products from you" (Product structured data, read 2026-09-11). A store's product page is a merchant listing. A review, a comparison, a "best X of the year" list, or a manufacturer page that sends people to retailers is a product snippet.

The two sets overlap on purpose, and the overlap runs one way. The same page says: "In general, adding the required product information properties for merchant listings means that your product pages can also be eligible for product snippets." So if you sell, build to the stricter set and you get both. If you do not sell, the merchant set is closed to you regardless of what you mark up — the eligibility page is explicit that "Only pages where a shopper can purchase a product are eligible for merchant listing experiences, not pages with links to other sites that sell the product" (Merchant listing structured data, read 2026-09-11).

PropertyMerchant listingProduct snippet
nameRequiredRequired
imageRequiredRecommended
offersRequired, must be OfferOne of offers / review / aggregateRating
offers.priceRequired, greater than zeroRequired inside an Offer; zero allowed
offers.priceCurrencyRequired with priceRequired with price
offers.availabilityRecommendedRecommended
AggregateOfferNot acceptedAccepted
gtin / sku / mpnRecommendedRecommended
shippingDetails, return policyRecommended, prefer Organization level
Pros and consEditorial reviews only

Three rows carry the decisions. Merchant listings require an Offer because "the merchant has to be the seller of the product in order to be eligible", and they "require a price greater than zero" — the snippet documentation, by contrast, shows "price": 0 as the way to mark something free. And a product snippet with offers but no rating may draw a warning, because "the product snippets section of the Rich Results Test may report a warning if you provide offers without review or aggregateRating properties" (Product snippet structured data, read 2026-09-11). A warning is not an error; read it as the test telling you which set it thinks you meant.

Mark up the page you have, not the page you wish you had: the offer must be buyable where the markup sits.

How to add it in five steps

Each step has a visible finish line. Do them in order — the first one decides everything after it, and most broken product markup we see skipped it.

  1. Classify the page. If the visitor can add the item to a cart on this URL, it is a merchant listing; otherwise it is a product snippet. Write the answer down before touching a template.
  2. Confirm the page is about one product. Google states that "product rich results only support pages that focus on a single product (or multiple variants of the same product)" and gives "shoes in our shop" as the counter-example. Category and collection pages get no Product markup.
  3. Write the block from the deliverable below, replacing every value with the one on the visible page. Price, currency and availability must match what the shopper sees; Google says it "may attempt to verify merchant listing product data before showing the information in search results".
  4. Put it in the initial HTML. The merchant page recommends exactly that "for best results", and warns that "dynamically-generated markup can make Shopping crawls less frequent and less reliable, which can be an issue for fast-changing content like product availability and price". Done when the audit command below sees it without a browser.
  5. Run the URL through the Rich Results Test, then watch the report Search Console files it under: "Merchant listings report: For pages where shoppers can buy products. Product snippets report: For other product related pages". Done when the report shows the page as valid, which can take days after the first crawl.

The deliverable: two blocks and one command

Block one is a merchant listing for a page that sells one product at one price. Every property present is either required or one of the recommended ones that costs nothing to fill; the shipping and return policies are referenced by @id because Google recommends declaring them once under Organization markup "instead" of repeating them on every offer.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Merino Crew Sock, Charcoal",
  "image": [
    "https://example.com/photos/1x1/merino-crew-charcoal.jpg",
    "https://example.com/photos/4x3/merino-crew-charcoal.jpg",
    "https://example.com/photos/16x9/merino-crew-charcoal.jpg"
  ],
  "description": "Mid-weight merino crew sock, reinforced heel, made in Portugal.",
  "sku": "MC-CHR-01",
  "gtin13": "0123456789012",
  "brand": { "@type": "Brand", "name": "Example Knitwear" },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/products/merino-crew-charcoal",
    "price": 18.00,
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "shippingDetails": { "@id": "https://example.com/shipping#policy" },
    "hasMerchantReturnPolicy": { "@id": "https://example.com/returns#policy" }
  }
}
</script>

Block two is a product snippet for an editorial review page — the page does not sell the sock, so there is no Offer from you, and the eligibility comes from the review. The reviewer's name has to be a person or a team; Google's own not-recommended example is "50% off on Black Friday".

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Merino Crew Sock, Charcoal",
  "image": "https://example.com/reviews/merino-crew/hero.jpg",
  "brand": { "@type": "Brand", "name": "Example Knitwear" },
  "review": {
    "@type": "Review",
    "author": { "@type": "Person", "name": "Dana Ortiz" },
    "reviewRating": { "@type": "Rating", "ratingValue": 4, "bestRating": 5 },
    "positiveNotes": { "@type": "ItemList", "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "No thinning at the heel after 40 washes" },
      { "@type": "ListItem", "position": 2, "name": "Stays up without a tight cuff" } ] },
    "negativeNotes": { "@type": "ItemList", "itemListElement": [
      { "@type": "ListItem", "position": 1, "name": "Only three colours" } ] }
  },
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.3, "reviewCount": 212 }
}
</script>

The command reads a live URL the way a crawler does — raw HTML, no JavaScript — pulls every JSON-LD block, walks into any Product node, and prints the properties the two requirement sets turn on. Keep the json.loads on the raw block: the script text is not HTML-escaped, and unescaping it first breaks any description that contains a quotation mark.

curl -sL -A 'Mozilla/5.0 (Macintosh) Chrome/126.0' https://example.com/products/some-product \
  | python3 -c "
import json, re, sys
doc = sys.stdin.read()
blocks = re.findall(r'<script[^>]*type=[\"\\']application/ld\+json[\"\\'][^>]*>(.*?)</script>', doc, re.S | re.I)
def walk(n):
    if isinstance(n, dict):
        t = n.get('@type')
        if t == 'Product' or (isinstance(t, list) and 'Product' in t): yield n
        for v in n.values(): yield from walk(v)
    elif isinstance(n, list):
        for v in n: yield from walk(v)
products = [p for b in blocks for p in walk(json.loads(b))]
print(len(blocks), 'JSON-LD block(s),', len(products), 'Product node(s)')
for p in products:
    o = p.get('offers') or {}
    o = o[0] if isinstance(o, list) else o
    print(p.get('name'), '|', o.get('@type'), o.get('price'), o.get('priceCurrency'), o.get('availability'),
          '| gtin', p.get('gtin') or p.get('gtin13'), '| sku', p.get('sku'), '| rating', 'aggregateRating' in p)
"

Run against one real store page on 2026-09-11 — a hoodie on gymshark.com — it returned one JSON-LD block containing seven Product nodes, all nested under a single ProductGroup that varies by size. Every variant carried its own 13-digit gtin and mpn, the shared sku, an image and an Offer at 40 USD with itemCondition and availability: four InStock, three OutOfStock. Brand, aggregateRating and review sat once at the group level and not on any variant. That is one page, chosen because it is a well-known store, and it is here as a shape to copy rather than a statistic.

The shape is the one Google's variant documentation describes: "use the ProductGroup class with associated properties variesBy, hasVariant, and productGroupID to group such variants together", with the group holding "common product-properties for all variants, such as brand and review information" (Product variant structured data, read 2026-09-11). The same page requires that each variant be reachable at its own URL that preselects it, so that Google can crawl each one.

Check before publishingSource rule
One product per marked-up page; no markup on collectionsTechnical guidelines, both pages
Merchant page: Offer, not AggregateOffer, price above zeroMerchant listing, Offer required properties
priceCurrency is a three-letter ISO 4217 codeBoth pages, Offer properties
One URL per currency if you price in severalTechnical guidelines
gtin is digits only, never a URLMerchant listing, Product recommended
priceValidUntil, if present, is in the futureMerchant listing: a past date may hide the listing
Markup is in the served HTML, not injected laterTechnical guidelines, JavaScript note
Values match the visible pageMerchant listing: Google may verify

Three ways it goes wrong on real stores

All three pass a syntax check. They fail on the eligibility rules, which no validator reads.

  1. Product markup on a collection page. A theme prints the first product's block on every listing URL, or marks the collection itself as a Product. Google's example of what a product is not — "shoes in our shop" — is that page. Remove it; a collection is an ItemList or nothing.
  2. A merchant page priced at zero, or with no currency. Free samples, "call for price", and templates that emit price: 0 for out-of-stock variants all disqualify the merchant listing, which needs a price above zero. The snippet set tolerates zero; the merchant set does not.
  3. Markup built by JavaScript after the page loads. The audit command above prints zero Product nodes, the Rich Results Test may still pass because it renders, and the Shopping crawl gets it late or not at all. Move the block into the server response.

Where this stops helping

Two limits. Markup makes a page eligible; it does not make anything appear — Google's troubleshooting section says plainly that it "does not guarantee that features that consume structured data will show up in search results", and we cannot tell you how often a valid merchant listing is actually shown. Second, structured data is one of two feeds Google reads for shopping: the overview page says you can "add Product structured data to your web pages, upload data feeds with Google Merchant Center and opt into free listings within the Merchant Center console, or both", and that doing both "maximizes your eligibility". This chapter covers the page half only. If your product pages are not being fetched at all, no markup fixes that; start from the AI crawler accessibility check and come back.

Common questions

Do I need product structured data on Shopify?

Check before you add any: most Shopify themes already print a Product block, and a second one from an app produces two competing nodes. Run the audit command on one product URL. If it prints one Product node with an Offer, a currency and an availability value, you are done; if it prints two, remove one. The parts of a hosted store you cannot change are listed in website builder SEO limits.

What does "product snippets structured data issues detected" mean?

Search Console found Product markup on pages it filed under the snippet report and at least one of them fails a rule — most often a missing offers, review or aggregateRating, since the snippet set needs one of the three. Open the report, click the issue, and fix the example URL first; the same template usually explains every row.

Product snippets vs merchant listings — which report do I read?

Merchant listings, if you sell. Google says that report "includes checks for product snippets that include Offer structured data, so the Product snippets report only needs to be consulted for non-merchant listing pages". Reading both for a store doubles the noise and adds nothing.

Is there a product schema checker I should trust?

The Rich Results Test, because it applies Google's rules rather than schema.org's. A generic validator will accept an AggregateOffer on a merchant page and a zero price, and both are ineligible.

How do I mark up product variants?

One ProductGroup with variesBy, productGroupID and a hasVariant list of Product nodes, each with its own identifiers, offer and a URL that preselects it. Shared facts — brand, rating, description — go on the group once. The gymshark page above is a working example of exactly that layout.

Part of the QueryWin handbook · Level 2

Product structured data: two rule sets, one decision, and the price that must be above zero