Check your product schema in five minutes
Your product page carries a summary you never see
Alongside the page a visitor reads, most Shopify product pages carry a small machine-readable summary: product name, price, currency, whether it is in stock. It is not rendered, so nobody browsing the shop encounters it. It is usually a schema.org Product record, carrying an offer with the price and currency inside it.
That summary can be missing entirely. It can be present with no price in it. It can be on some of your page templates and not others. The shop looks completely normal in all three cases.
We measured 136 product pages across 23 live Shopify stores. Eighteen carried no product summary at all. One store's summary sits outside the block a standard reader looks in. Two stores have it on some templates and not others.
Checking your own takes about five minutes and two commands. Here they are first, then what we found.
Check yours in five minutes
Ask your own page what it serves before you ask a tool what it thinks.
Start by following redirects, or you will measure a redirect instead of a page. Twelve of the 24 stores we fetched — the 23 in the table below plus one excluded — redirect a bare request for a product page. Eight swap the bare domain for www; four go the other way. All 24 returned 200 when checked this way on 2026-08-16.
curl -sL -A 'Mozilla/5.0' -o /dev/null -w '%{http_code}\n' https://yourstore.com/products/your-product
Then run two searches and compare which record types each one lists. The first looks at the whole response; the second looks only inside labelled blocks:
URL=https://yourstore.com/products/your-product
curl -sL -A 'Mozilla/5.0' "$URL" \
| grep -oE '"@type"[[:space:]]*:[[:space:]]*"(Product|ProductGroup|Offer)"' | sort -u
curl -sL -A 'Mozilla/5.0' "$URL" | tr '\n' ' ' \
| grep -oE '<script[^>]*application/ld\+json[^>]*>[^<]*' \
| grep -oE '"@type"[[:space:]]*:[[:space:]]*"(Product|ProductGroup|Offer)"' | sort -u
Run it on more than one page. Two of the four stores missing records were missing them only on some templates, and one page cannot see that. Try a plain product, a variant, and something on sale.
What your result means
Compare the two lists, not their lengths.
Both lists show the same types. The record is where a reader expects it, on that page. Running these commands against one sampled page from each of the 23 stores, 19 came back like this.
That is a result about the page you tested, not about your shop, and our own data shows why the distinction matters. Bombay Shaving's sampled page came back healthy; one of its six pages has no record at all. The Sleep Company's sampled page came back empty, yet one of its six does have one. Both stores would be misread from a single page, in opposite directions.
Both lists are empty. No JSON-LD product record at all. Three of our 23 stores looked like this.
Before concluding it is your whole shop, run it on three or four more products from different templates — the two stores above are the reason. If every template comes back empty, the thing to look at is your theme, or an app that replaced the section which used to emit it. Shopify's own page says its structured_data Liquid filter handles this automatically for product pages, so an empty result on a Shopify store is worth raising with whoever maintains yours. We did not test any fix, so treat that as where to look rather than what to do.
Types in the first list, nothing in the second. The record is in the page, but nothing that looks for the standard label will find it. One store in our sample, described below.
Two separate things to check here, and they are easy to conflate. The first is the label: emitting the type attribute from the server makes the record visible to a reader that does not run JavaScript. The second is whether a price is actually inside the record — in the one case we examined closely, it was not. Fixing the label alone would leave that.
You see ProductGroup. Some or all of your prices may be nested one level down, inside hasVariant. This is correct markup for products with sizes or colours, and Google documents it. On Allbirds the prices sit in both places at once.
The product is on sale and you expected a struck-through price. The variant causing it often has a compare-at price in the box already, just not one above its own price — which we found by counting 5,444 products on sale across 18 shops: Shopify compare-at price not showing.
Three honest limits on this check. It tells you where your record is, not what is inside it. It reports which types are present and where they sit. It will not tell you whether the price in the record is the one you meant, or whether that price appears anywhere a visitor can see. And it only sees JSON-LD; microdata is an older encoding these commands do not read. Nor does it say anything about AI answers: Google's optimization guide for AI search says "Structured data isn't required for generative AI search". Checking what those answers say about your products is covered in Generative engine optimization: get your product facts right.
It costs under a minute once the commands are in your shell history, which makes it worth re-running after you switch theme, install an app that touches product pages, or add a new product template. We did not measure how often markup changes under a shop's feet, and we did not test whether anything would tell you if it did. Looking is cheap enough not to need the answer.
Google's Rich Results Test is a useful second opinion. Read Google's own note on it first: it fetches with a separate inspection agent, and does not guarantee your page will appear exactly as shown.
What actually breaks, on 23 real shops
Five minutes of your time deserves more than an assertion, so here is what we found and how we found it.
Two different measurements sit behind this article, and they are worth keeping apart. The two commands above were run on 23 pages — one sampled page per store. The 136-page figures below come from a separate script that fetched every sampled page and parsed it, which is how a count that size is possible at all.
First, why nobody told you this. On 2026-08-16 we read the top three results for json ld product schema and the top three for add structured data to shopify. All six explain how to add structured data. None tells you how to see what your own live page already serves.
| Page we read | Says how to inspect your live HTML | Mentions ProductGroup | Mentions JavaScript-only prices | Reports its own data | Mentions UnitPriceSpecification | Says a theme may already emit it |
|---|---|---|---|---|---|---|
| Yotpo, Schema Markup For Ecommerce | no | no | no | no | no | no |
| Schema App, How to Create Product Schema | no | no | no | no | no | no |
| schemavalidator.org, Product Schema JSON-LD | no | no | no | no | no | no |
| Bidnamic, Structured data for Shopify stores | no | no | no | no | no | no |
| Shopify Community thread 59738 | no | no | no | no | no | no |
| Shopify, Ecommerce Schema | no | no | no | no | yes | yes |
That is one read, on one day, of six pages, not a survey of the category. The last two columns exist because Shopify's own page is the strongest of the six and six flat noes would misrepresent it. Hold on to its second credit — a theme may already be emitting product schema without being asked. It decides how much of the rest of this matters.
We started with 40 store addresses, picked by brand recognition across Indian, American and British brands. Of those, 24 served a public catalogue. Every store here runs Shopify, because that is how we drew the sample.
From each store we took the first six products in catalogue order, after discarding internal SKUs and anything priced under 5 units of the store's currency. That gave 144 pages. Six belong to beminimalist.co, which we picked in an earlier round because we already knew it had a problem, so it is out. Two more are Dr Squatch handles that its own catalogue lists but that answer 200 with a page telling the visitor they might be lost.
We did not take that on the strength of a phrase. Those two pages carry 2,181 and 2,185 characters of visible text, against 6,574 on a working product page from the same store. What we measured is 136 pages across 23 stores.
| What turned up | Pages | Stores affected |
|---|---|---|
| No product record in any form | 18 | 4 |
| Record present but not in a labelled block | 6 | 1 |
| ProductGroup rather than Product | 24 | 4 |
| Declared price found nowhere in the visible text | 5 | 1 |
| og:price:amount present | 108 | 18 |
Eighteen pages carried no schema.org product node anywhere in the response. On tentree, a product page fetched with a plain HTTP request contains no schema.org product node in any form. Every page named here returned a real product page when re-fetched by hand. Pipsnacks serves no product node on any of its six pages but does publish an Open Graph price. In the other two stores it is patchy — one page of six at The Sleep Company, five of six at Bombay Shaving. Patchy is the more interesting failure, because nothing tells the merchant which templates are missing it.
What we expected to find, and did not, was wrong prices. On the 58 pages where we could locate the product's own price element, the declared price was among the prices the page rendered. There were no exceptions. On 23 the declared and rendered sets were identical. On 25 the page rendered an extra value the markup did not declare. On the remaining 10 the markup declared more values than the page rendered.
A further 54 pages could be compared only by reading every number on the page. Forty-nine agreed. On five our reader found no match, and we opened all five. All five are plumgoodness, whose declared price appears nowhere in its visible text; what our reader found instead was the store's banner, offering a free box worth 950 on orders above 1599.
| Store | Pages | Labelled record | Unlabelled record | ProductGroup | Price readable | Agreed |
|---|---|---|---|---|---|---|
| allbirds.com | 6 | 6 | 0 | 6 | 0 | 6 |
| arata.in | 6 | 6 | 0 | 0 | 6 | 6 |
| bluetokaicoffee.com | 6 | 6 | 0 | 0 | 0 | 6 |
| boldcare.in | 6 | 6 | 0 | 0 | 6 | 6 |
| bombayshavingcompany.com | 6 | 5 | 0 | 0 | 0 | 5 |
| deathwishcoffee.com | 6 | 6 | 0 | 0 | 6 | 6 |
| drsquatch.com | 4 | 4 | 0 | 0 | 0 | 4 |
| mcaffeine.com | 6 | 6 | 0 | 0 | 0 | 6 |
| plumgoodness.com | 6 | 6 | 0 | 0 | 1 | 1 |
| sleepyowl.co | 6 | 6 | 0 | 0 | 0 | 6 |
| sugarcosmetics.com | 6 | 6 | 0 | 0 | 2 | 6 |
| tentree.com | 6 | 0 | 0 | 0 | 0 | 0 |
| www.beardbrand.com | 6 | 6 | 0 | 0 | 6 | 6 |
| www.boat-lifestyle.com | 6 | 6 | 0 | 0 | 6 | 6 |
| www.brooklinen.com | 6 | 0 | 6 | 0 | 0 | 0 |
| www.chumbak.com | 6 | 6 | 0 | 0 | 0 | 6 |
| www.lucyandyak.com | 6 | 6 | 0 | 6 | 6 | 6 |
| www.mamaearth.in | 6 | 6 | 0 | 0 | 6 | 6 |
| www.mokobara.com | 6 | 6 | 0 | 6 | 0 | 6 |
| www.pipsnacks.com | 6 | 0 | 0 | 0 | 0 | 0 |
| www.taylorstitch.com | 6 | 6 | 0 | 6 | 6 | 6 |
| www.thesleepcompany.in | 6 | 1 | 0 | 0 | 1 | 1 |
| www.vahdam.com | 6 | 6 | 0 | 0 | 6 | 6 |
| Total | 136 | 112 | 6 | 24 | 58 | 107 |
Dr Squatch shows four pages because its other two are the ones dropped above. Labelled record counts pages whose product data sits inside a <script type="application/ld+json"> block; Unlabelled record counts pages where the data is in the response but not in such a block. Price readable counts pages where we could locate the product's own price element; Agreed counts pages whose declared price we found by any method.
A zero under Price readable is usually a limit of a plain HTTP fetch, not a fault in the store. A zero under Labelled record and Unlabelled record at the same time is a different thing. That is a finding about the store, and every page behind one was re-fetched by hand and returned a real product page.
That price result rests on something narrower than it sounds. On a Shopify theme the same Liquid value usually renders into both the structured data and the visible price, so 23 stores is closer to a handful of theme templates than to 23 independent implementations.
It cuts the other way too. If a theme may already be emitting product schema without being asked, then a sample drawn entirely from Shopify stores is the hardest place to find product markup missing. We still found 18 pages with none of it, one store whose record no standard label points at, and two stores carrying it on some templates and not others.
The shop whose record carries no label
Brooklinen's product pages carry a schema.org Product record in the HTML their server sends: name, brand, SKU, offer, currency, shipping policy, return policy. What they do not carry is the label. The record sits in an ordinary <script> block as a JavaScript variable, and the page's own code builds a new application/ld+json script from it once the browser has run.
Ask for the labelled block and you get a breadcrumb trail. Search the whole response and the product record is there. That is exactly the third outcome above, and it is why the check compares two lists rather than one.
One detail makes it sharper than a labelling quibble. The offer literal in the served bytes has no price key at all — the price is attached at runtime. It carries priceCurrency and no amount to go with it. Google's product snippet documentation lists price or priceSpecification.price as a required property of Offer; the served literal has neither. That is a statement about what the documentation requires, not a prediction about what Google does with the page.
The page is not silent, though. Its own JavaScript data carries prices in the served bytes, tied to each variant and SKU. What is missing is a price in either of the two formats a machine is meant to read without knowing anything about Brooklinen in particular.
We are not claiming this costs Brooklinen anything, and we did not test whether it does. Emitting the type attribute from the server would make the record visible to a reader that does not run JavaScript — but it would not put a price in it, because the price is attached at runtime either way.
What we got wrong, and what we did not measure
Our instrument produced false findings about other people's markup eleven times, and every one made a store look worse than it is. That is not a coincidence: a reader that fails to find something reports an absence, and an absence reads like a finding.
It read promotional banners as product prices, and the fix for that deleted the stores' own prices. It read ProductGroup markup as no markup. It read a currency declared inside a priceSpecification as missing. It could not read Rs. as a currency, which mis-filed 18 pages across three stores. Three of the eleven were created by fixing another one. Not one was caught by reading the tool's summary — they were caught by opening pages and reading individual rows. Every result here that says something was missing or did not match was re-checked by a second method before this was written — all 18 pages with no record, and all five where our reader found no match.
One limitation is still live: we take the first price element on the page, so a theme printing a struck-through original before the sale price would be misread. No such case appeared here.
We did not test what Google does with any of these pages. We fetched each URL the way a simple script would and read what came back. So a record we could not reach is a reason to check your own store, not evidence that anything wrong ever reached a shopper. Our requests also came from India and shops localise, so Lucy & Yak, a British brand, served us rupees. And every finding above was true on 2026-08-16 — any store can change its markup tomorrow.
Check our working
Every row behind every number here is published as the raw measurement file — one line per page, including the ones we could not read and the two we dropped, with the verdict and the reason for each. If you run the commands above against a store we named and get a different answer, that file is where to start.