Skip to content

Structured data

Product schema: price, availability and reviews for rich results

Product markup puts price, stock and stars under your listing. Learn what Google requires, how to write offers, variants and ratings, and why shops end up with two Products.

getReport teamUpdated 25 Sept 202610 min read

A product listing in Google can be a plain blue link, or it can show "€129.00 · In stock · ★ 4.6 (84)" under the title. The difference is a block of Product structured data with the right properties filled in the right format. Most shops already have some Product markup from their platform; what they lack is one property, one format detail or one duplicate removed. This guide shows what Google requires, how each field should look, and how to check a product page in a minute.

Quick answer

  • A product page needs one Product entity with name, image and offers.
  • The Offer needs price (a number with a dot, no currency sign), priceCurrency (three-letter code such as EUR) and should have availability as a schema.org URL (https://schema.org/InStock).
  • Ratings go in aggregateRating with ratingValue and reviewCount (or ratingCount), and only when the reviews are shown on the page.
  • Sizes and colours are variants: one Offer per variant, or a ProductGroup with hasVariant.
  • One Product per product. WooCommerce plus an SEO plugin, or Shopify plus a review app, can print two.
  • Run the schema validator on a product page and fix what it lists.

Why product schema matters

Search results with a price, stock status and stars are larger, answer the shopper's first two questions before the click, and stand out against plain listings on the same page. Product markup is also what makes a page eligible for Google's shopping surfaces beyond the classic result: the Shopping tab, product grids and image results with a price badge.

Google recognises two kinds of product page. Product snippets are for pages about a product, including review sites that do not sell it; they need a name and at least one of review, aggregateRating or offers. Merchant listings are for pages where the visitor can buy the product; they need name, image and an Offer with a price and currency, and they are what a shop should aim for. Google's product snippet documentation and merchant listing documentation list every property.

Google only shows product rich results for pages that focus on a single product, or on variants of one product. Category pages listing twenty products do not qualify, however they are marked up.

How getReport checks it

The validator reads the JSON-LD in the product page's HTML, lists every top-level entity, and checks each Product for the merchant-listing basics: name, image, offers, a price (offers.price, or offers.lowPrice for a price range) and offers.priceCurrency. When offers is missing altogether, only offers is reported; add it, and the finding moves on to what the Offer lacks.

The required properties finding on a product page whose Product block has no offers: the title names the missing property and the Product type, and the detail lists each gap as type and property
Each line in the detail is one property to add, in the form type: property.

On a product page, you want to see Product once, next to the site-wide entities (Organization, WebSite, BreadcrumbList). Product ×2 means two blocks describe the same product.

Two limits worth knowing. getReport checks entities that sit at the top level of a block or in an @graph. An aggregateRating nested inside the Product, and the variants inside a ProductGroup, are not checked property by property; the Rich Results Test covers those. And a review site without prices will still be asked for offers, because the check follows the merchant-listing requirements; if you do not sell the product, that line can be ignored.

Step by step

1. Start from what the platform prints

Run the validator on one product page. If the types finding already shows Product, you are editing, not writing: find where the block comes from (the theme, WooCommerce, an SEO or review plugin) and fix the data or the settings there. Hand-adding a second block is how duplicates happen.

2. Write the Offer correctly

The Offer is where most errors live, because each field has a format:

PropertyCorrectWrong
price129.00 or "129.00""€129", "129,00", "1.299,00"
priceCurrency"EUR", "USD", "GBP" (ISO 4217)"€", "euro"
availability"https://schema.org/InStock""In stock", "available"
itemCondition"https://schema.org/NewCondition""new"
priceValidUntil"2026-12-31" (a future date)a date that has passed
urlthe product (or variant) page URLthe home page

The availability values Google reads are InStock, OutOfStock, PreOrder, BackOrder, LimitedAvailability, OnlineOnly, InStoreOnly, SoldOut and Discontinued, each as a full https://schema.org/… URL. priceValidUntil is optional; set it only if the price really ends on a date, because a date in the past can stop the snippet from showing.

The price must be the one a visitor sees and pays on the page, in the same currency. If the shop shows prices in several currencies, the markup follows the one on the page as served.

3. Add shipping and returns if you sell online

Google uses shippingDetails and hasMerchantReturnPolicy to show delivery costs and return windows in shopping results. They are recommended, not required. A store-wide return policy can also be declared once on your Organization markup or set in Google Merchant Center instead of on every product. The complete example below includes both.

4. Add ratings only from reviews on the page

An aggregateRating needs ratingValue and a count: reviewCount (reviews with text) or ratingCount (star ratings). Without a count, Google does not show stars. If your scale is not 1 to 5, add bestRating and worstRating.

Google's review snippet guidelines come down to three rules for shops:

  • The reviews must be visible on the product page, not only in the markup.
  • They must come from real customers, collected by you or by your review app, not copied from other websites.
  • The rating must be about this product. Stars in your Organization or LocalBusiness markup from reviews on your own site are "self-serving" and are not shown; this restriction does not apply to customer reviews of a product.

5. Handle variants

If a shoe comes in six sizes with one price and one page, a single Offer is fine, with availability for the product as a whole. When variants differ in price or stock, give each its own entry. Google documents ProductGroup for this: one group with productGroupID, variesBy and a hasVariant list of Products, each with its own sku, variant property and Offer (see Google's product variants documentation).

JSON
{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "@id": "https://example.com/shoes/alpina-pro/#productgroup",
  "name": "Alpina Pro trekking shoe",
  "productGroupID": "ALP-PRO",
  "brand": { "@type": "Brand", "name": "Alpina" },
  "variesBy": ["https://schema.org/size"],
  "hasVariant": [
    {
      "@type": "Product",
      "sku": "ALP-PRO-41",
      "name": "Alpina Pro trekking shoe, size 41",
      "size": "41",
      "image": "https://example.com/img/alpina-pro-1.webp",
      "offers": {
        "@type": "Offer",
        "url": "https://example.com/shoes/alpina-pro/?size=41",
        "price": 129.00,
        "priceCurrency": "EUR",
        "availability": "https://schema.org/InStock"
      }
    },
    {
      "@type": "Product",
      "sku": "ALP-PRO-42",
      "name": "Alpina Pro trekking shoe, size 42",
      "size": "42",
      "image": "https://example.com/img/alpina-pro-1.webp",
      "offers": {
        "@type": "Offer",
        "url": "https://example.com/shoes/alpina-pro/?size=42",
        "price": 129.00,
        "priceCurrency": "EUR",
        "availability": "https://schema.org/OutOfStock"
      }
    }
  ]
}

A simpler alternative for a price range is an AggregateOffer with lowPrice, highPrice, priceCurrency and offerCount; it works for product snippets, but it cannot say which size is out of stock.

6. Put it together

A complete Product for a simple product page, in a <script type="application/ld+json"> element in the product template. It passes every getReport check and carries the recommended merchant-listing fields:

JSON
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "https://example.com/shoes/alpina-pro/#product",
  "name": "Alpina Pro trekking shoe",
  "description": "Waterproof trekking shoe with a grippy rubber sole, 780 g per pair.",
  "image": [
    "https://example.com/img/alpina-pro-1.webp",
    "https://example.com/img/alpina-pro-2.webp"
  ],
  "sku": "ALP-PRO",
  "brand": { "@type": "Brand", "name": "Alpina" },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/shoes/alpina-pro/",
    "price": 129.00,
    "priceCurrency": "EUR",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": { "@type": "MonetaryAmount", "value": 4.90, "currency": "EUR" },
      "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "HR" },
      "deliveryTime": {
        "@type": "ShippingDeliveryTime",
        "handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1, "unitCode": "DAY" },
        "transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 4, "unitCode": "DAY" }
      }
    },
    "hasMerchantReturnPolicy": {
      "@type": "MerchantReturnPolicy",
      "applicableCountry": "HR",
      "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
      "merchantReturnDays": 30,
      "returnMethod": "https://schema.org/ReturnByMail",
      "returnFees": "https://schema.org/FreeReturn"
    }
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": 4.6,
    "reviewCount": 84
  },
  "review": [
    {
      "@type": "Review",
      "author": { "@type": "Person", "name": "Marko K." },
      "datePublished": "2026-08-14",
      "reviewRating": { "@type": "Rating", "ratingValue": 5 },
      "reviewBody": "Dry feet after a full day of rain on Velebit."
    }
  ]
}

Every value here must appear on the page: the price, the stock status, the rating and the review. For the general rules of JSON-LD (where the script goes, @id, escaping), see JSON-LD basics.

7. Re-run and confirm with Google

Run the validator again: required properties should pass, the eligibility finding should list Product as eligible, and the types should show one Product. Then run Google's Rich Results Test on the same URL; it also checks the nested rating, the variants and the shipping and return fields.

Platform notes

WordPress and WooCommerce

WooCommerce prints Product JSON-LD on every product page by itself, built from the product's price, stock status, images and reviews. Variable products get an AggregateOffer with the lowest and highest variant price. Most gaps are data gaps: a product without a featured image has no image, a product with an empty price has no price. Fix them on the product itself.

The doubling happens when an SEO plugin adds its own Product. Yoast's WooCommerce add-on and Rank Math both integrate with WooCommerce and are meant to produce one Product inside their graph; when that integration is off, or a third schema plugin joins, the page carries two. The types finding then shows Product ×2; when both blocks carry the same name and URL, the duplicate check lists the product by name. If two SEO or schema plugins each print a graph, the WordPress checks name both. Keep one source, as described in schema markup validation.

Shopify

Shopify themes print Product JSON-LD from the product template; newer themes use Shopify's structured_data Liquid filter to build it from the product object. Review apps add ratings in one of two ways: by extending the theme's Product, or by printing their own Product block with an aggregateRating. The second way creates a duplicate. Check the app's settings for a "rich snippets" or "schema" option, and keep one Product carrying both the offer and the rating.

Static sites and custom builds

Generate the block from the same product record that renders the page, so price and stock can never disagree with what the visitor sees. Cached pages are a special risk: when a product sells out, the cached HTML may keep saying InStock until the cache expires.

Verify

  • The validator shows one Product, no missing required properties, and Product among the types eligible for rich results. The rich results learn page shows what each result looks like.
  • Google's Rich Results Test lists "Merchant listings" and "Product snippets" as valid, with no errors on the rating or the Offer.
  • After the next crawl, Search Console's shopping and product reports show the page as valid. Stars and prices can take a few weeks to appear, and Google decides per search whether to show them.

Common mistakes

  • Price with a currency sign or comma decimal. "€129,00" is not a number. Use 129.00 and put the currency in priceCurrency.
  • Availability as plain text. "In stock" is not recognised. Use https://schema.org/InStock.
  • Rating without a count. aggregateRating with only ratingValue earns no stars. Add reviewCount or ratingCount.
  • Markup for products not on the page. A category page marked up with twenty Products, or a rating that appears only in the markup, is against Google's guidelines. One product (or product group) per page, and every value visible.
  • Two Products from two sources. Theme plus plugin, or plugin plus review app. The types list shows Product ×2; turn one off rather than trying to make them agree.
Check your site before and after Check