Skip to content
pageinspection

Product schema: the properties that decide eligibility

Every property on a Product block is a number, a date or a claim about stock, which makes this the one type where the markup is wrong for reasons that have nothing to do with syntax. A currency nobody set, a comma the storefront formatted in, a validity date from last spring. All three parse.

The short answer

Product markup needs an offer with both a price and a currency, because either alone is meaningless. availability takes a schema.org URL such as https://schema.org/InStock rather than the word in stock. Ratings need a review count to be a denominator, and reviews have to come from someone other than the seller.

ImportantAudit check · Product Schema

A price and a currency are one fact in two properties

price and priceCurrency live on the Offer, not on the product, and neither is usable without the other. Ship the first alone and you have published a decimal number with no unit attached to it. Nothing rejects that, and nothing shows it either, which is why it is the disqualification you see most often on otherwise finished blocks.

It happens because of where the two values come from. A storefront knows its currency from the store settings, the locale or the domain, so the price column in the database is a bare decimal and the template that renders the page adds the symbol at display time. The template that writes the JSON-LD reads the column instead, and the currency never makes it across. The fix is one line and the reason it was missing is worth understanding, because the same gap produces the next problem in this section.

priceCurrency takes a three-letter ISO 4217 code in capitals: USD, EUR, GBP, JPY. Not a symbol, not usd, not the word dollars. And price takes a plain number as a string, which is where the formatting habits of the display layer do real damage.

price-values.txt
wrong                right
-------------------  --------------------------------------------
"$89.00"             "price": "89.00", "priceCurrency": "USD"
"89,00"              "89.00"
"1,099.99"           "1099.99"
"89.00-129.00"       two Offer objects, or one AggregateOffer with
                     lowPrice and highPrice
"Call for pricing"   no price property, and no Offer either
"89.00 EUR"          keep the code in priceCurrency, on its own

The second and third rows are the ones to be afraid of, because they do not fail. A European storefront formatting 1.099,99 into its markup has published a price a parser can read as one point zero nine nine, and a thousands separator in 1,099.99 can truncate the same way. Both are silent. A missing currency at least leaves you with nothing; a mis-parsed separator leaves you advertising a knife for a euro, and the first you hear of it is a customer screenshot.

If your prices are genuinely locale-formatted at the source, serialise the markup from the raw numeric value rather than from the string you already rendered. Interpolating the display string is the shortcut that causes this every time.

availability takes a URL, not an adjective

availability expects one of the ItemAvailability enumeration members, written as a schema.org URL: https://schema.org/InStock, https://schema.org/OutOfStock, https://schema.org/PreOrder, https://schema.org/BackOrder, https://schema.org/Discontinued, https://schema.org/LimitedAvailability, https://schema.org/SoldOut, https://schema.org/InStoreOnly and https://schema.org/OnlineOnly.

What turns up in real markup instead is true, Available, in stock, Yes or the label printed on the button. None of those is a member of the enumeration, so none of them says anything. Consumers are known to accept the bare member name without the URL prefix, and you may well get away with InStock, but the documented form is the full URL and it costs you nine characters to be certain. Use it.

Then there is the property that quietly expires. priceValidUntil is a date, and a date in the past says this offer is no longer current. Hardcode it once, or generate it as launch day plus a year, and the block goes from correct to self-cancelling on a morning nobody is watching. Either emit it from something real, a promotion end or a contract date, or leave it out. A stale priceValidUntil is the only property here that can break a page you have not touched in two years.

product.json
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Fielding 20cm carbon steel chef knife",
  "description": "Forged carbon steel, full bolster, 20cm blade.",
  "sku": "KN-2000-CS",
  "gtin13": "5012345678900",
  "image": ["https://example.com/img/kn-2000-cs-16x9.jpg"],
  "brand": { "@type": "Brand", "name": "Fielding Cutlery" },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/knives/kn-2000-cs",
    "price": "89.00",
    "priceCurrency": "EUR",
    "priceValidUntil": "2026-11-30",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition",
    "seller": { "@id": "https://example.com/#organization" }
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.4",
    "bestRating": "5",
    "ratingCount": "112"
  },
  "review": [
    {
      "@type": "Review",
      "author": { "@type": "Person", "name": "Marta Iversen" },
      "datePublished": "2026-05-02",
      "reviewRating": {
        "@type": "Rating",
        "ratingValue": "4",
        "bestRating": "5"
      },
      "reviewBody": "Holds an edge far longer than my old stainless one, but it will rust if you leave it wet."
    }
  ]
}

itemCondition is an enumeration too, and it matters more than it looks on any site selling refurbished or used stock, because the default assumption is new. seller references an entity rather than repeating your company name, the same move the Organization guide argues for everywhere else.

Check one page for a Product block

This reports whether the URL you enter declares a type in the product family. Everything this guide is about sits inside the block, so a pass tells you the type is there and says nothing about the currency, the availability member or the date.

Check one page for Product markup

No signup required. Each free search audits one page, paste any URL to see it in action.

Ratings need a denominator, reviews need a stranger

aggregateRating is two facts, not one. A ratingValue on its own is a score with no sample behind it, so it needs a ratingCount or a reviewCount alongside it. The two counts are not synonyms and the distinction is worth getting right: ratingCount is how many people scored the product, reviewCount is how many wrote something. A shop with four hundred stars and eleven written reviews has both numbers and they are different.

Declare bestRating if your scale is not out of five. Leave it off and the value is read against the default, so an eight out of ten arrives as an eight out of five, which is the one arithmetic error in this type that a validator will not query.

Then the trap in the title of this page. review needs an author, and the author cannot be you. Google's policy is that ratings come from real customers, and a review of your own product, written by your own staff, marked up as a Review on your own page, is the case it exists to exclude. This is not a technical error and no tool will report it. It is a policy problem, and the remedy is to remove the markup rather than to reword it.

"Our verdict" is editorial content. Marked up as a Review, it is a rating you gave yourself.

The pattern is common on sites that both write about products and sell them. The paragraph is honest, the star graphic is honest, and the moment a Review block wraps it on a page with an Offer underneath, you are the seller and the reviewer at once. If the editorial verdict genuinely matters to you, publish it and keep it out of the markup, or move it to a page that sells nothing.

The strings that give this away in an audit are author values reading Admin, Staff, Verified Buyer or your own company name. All four are valid markup naming nobody.

Two different results read this block, with two different rules

Product markup feeds more than one presentation, and they do not want the same things. A product snippet is the rating, price and availability line attached to an ordinary blue link, and it can be earned from a review or an aggregate rating with no offer present at all. A merchant listing is entry to the shopping surfaces, and there an offer with a price and a currency is the point of the exercise, with product identifiers, shipping details and a return policy raising how far you get.

The consequence is that clearing one does not clear the other, and a report showing errors for one feature and nothing for the other is behaving correctly rather than contradicting itself. Requirements belong to features rather than to types, which is the general rule the required-properties guide sets out. Product is simply where you notice it, because it is the one common type with two live features reading the same block.

One over-application to avoid while you are in here. Product markup describes the item this page is about, so a category listing that emits a Product block for each of its forty tiles has declared forty products on a page that sells none of them. If you want to describe a listing, describe the list. And a page selling one item in six sizes is a ProductGroup with hasVariant and a productGroupID, not six unrelated products stacked in the head.

Why the type with money attached to it is only Important

Because nothing on the page stops working. A product page with no markup is crawled, indexed, ranked and bought from. Critical on the severity scale is reserved for failures that take a page out of the running altogether, and this is not one of them. It is also, on most sites, a finding scoped to a minority of URLs: the catalogue, and nothing else.

What lifts it clear of Refinement is that the loss is visible to the person deciding. On a shopping query your result is drawn next to results carrying a price, a stock line and a star rating, and the searcher can see which listing tells them what they wanted to know before they click. Most audit findings cost you something only you can measure. This one costs you a side-by-side comparison in front of the customer, on the exact pages where a click has a transaction behind it, which is why it belongs in a plan rather than in the polish list.

The rating also reflects a fix that is a template change rather than a data project. Price, stock and currency already exist in your system, because the storefront could not function without them. Getting them into the markup is plumbing, and it is the reason this sits below the schema failures whose remedy is somebody starting to record a value nobody records.

Confirming the markup still agrees with the checkout

Two passes. First the feature question, then the drift question, and the second is the one that matters six months later.

  • Read which feature you qualified for. The Rich Results Test names the feature it detected, not just an error count. If it reports a product snippet and you were building for the shopping surfaces, the block is valid and you have cleared the wrong bar.
  • Treat the two Search Console reports as separate. Merchant listings and product snippets are reported apart, so a clean row in one and four hundred errors in the other is a real state and not a glitch.
  • Compare the block against the page, on a product whose price changed this month. Pick the item most recently repriced rather than a random URL. That is where an export lag shows up.
check-offers.sh
# the offer, as the page actually serves it
curl -s https://example.com/knives/kn-2000-cs \
  | grep -o '"\(price\|priceCurrency\|availability\)": *"[^"]*"'

# any offer that stopped being valid before today
curl -s https://example.com/knives/kn-2000-cs \
  | grep -o '"priceValidUntil": *"[0-9-]*"' \
  | grep -o '[0-9-]\{10\}' \
  | awk -v today="$(date +%F)" '$1 < today { print "expired: " $1 }'

Run the second command across your sitemap once and you will find out whether anything on the site is emitting a validity date from a previous financial year. It is the cheapest audit in this guide and the one nobody thinks to run, because the block it breaks was correct on the day it shipped.

Last, the sanity check no script does. Say the price out loud with its currency, and say the availability member as a sentence about your warehouse. If either sounds wrong, the markup is describing a different shop.

Questions this check raises

Why is my Product markup not showing a price?
Most often because price and priceCurrency are not both present, or because availability is a plain word rather than a schema.org URL. Both are silent failures: the block validates as schema.org and the specific enhancement is simply not granted.
Can I mark up my own reviews of my own product?
No. Google requires reviews to come from independent sources rather than from the seller, and self-serving review markup is a documented cause of manual actions. Aggregating genuine customer reviews you collected is legitimate; writing them is not.
Does aggregateRating need a review count?
Yes, and a rating without one is the most common incomplete Product block. A 4.8 with no denominator could be one review or ten thousand, so the value is not usable and the enhancement is not granted.