Structured Data for Shopify: The Product Schema Google Actually Reads
·
Complete guide to schema.org structured data for e-commerce: Product, Offer, Reviews, Breadcrumbs, Organization. Google-focused strategy, JSON-LD examples, Shopify apps, validation tools, and best practices.
Almost every Shopify store already emits Product structured data. The theme does it for you. The problem is rarely "missing schema." It is conflicting, incomplete, or stale schema that makes a product ineligible for rich results and Google Merchant free listings. This guide is Shopify-specific: the exact fields Google reads, the markup for variants that most tutorials skip, and the two traps that silently disqualify real stores.
Why your store probably already has structured data, and why it's wrong
Shopify's Online Store 2.0 themes (Dawn and its forks) ship a product JSON-LD block inside the product section. The moment you install an SEO app (Smart SEO, JSON-LD for SEO, SearchPie), a second Product block is injected. Google now sees two Product objects on one URL, picks one non-deterministically, and often reads the one with the weaker data. That one has no priceValidUntil, no shipping, wrong currency. You didn't lose the markup; you poisoned it.
The first fix on any Shopify store isn't "add schema," it's "find the duplicate." View source, search application/ld+json, and count the Product blocks. If there is more than one, disable the theme's or the app's, never run both.WS24, first thing we check on a structured-data audit
The Product + Offer fields Google actually requires
For a product to be eligible for rich results and the free Merchant listings in the Shopping tab, Google needs more than name/price. The fields below are the ones whose absence shows up as "Non-critical issues" in the Rich Results Test and as warnings in Search Console's Merchant listings report:
offers.priceandoffers.priceCurrencymust match the price the shopper actually sees (see the Markets trap below).offers.availability: a schema.org URL (InStock/OutOfStock), not the string "in stock".offers.priceValidUntil: omit it and Google may drop the price from the snippet.offers.shippingDetailsandoffers.hasMerchantReturnPolicy: required for Merchant listings eligibility; missing = warnings, not errors, but they cost you the enhanced listing.aggregateRating/review: only if the reviews are genuinely collected from customers. Self-serving markup (a store rating itself) is ignored under Google's review-snippet policy.
Correct Product JSON-LD, rendered from the Shopify object
Do not hardcode values. Render them from the live product object so price, stock, and SKU never drift from the catalog. This is a real snippet you can drop into a snippets/product-schema.liquid and render once from the product template:
{%- assign v = product.selected_or_first_available_variant -%}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ product.title | json }},
"image": {{ product.featured_image | image_url: width: 1200 | prepend: "https:" | json }},
"description": {{ product.description | strip_html | truncate: 300 | json }},
"sku": {{ v.sku | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": "{{ v.price | divided_by: 100.0 }}",
"availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}",
"priceValidUntil": "{{ 'now' | date: '%Y' | plus: 1 }}-12-31"
}
}
</script>
Two details that matter: v.price is in cents, so divide by 100; and cart.currency.iso_code, not shop.currency, is what makes the currency follow the buyer's market. That second point is the trap.
The Shopify Markets price/currency mismatch
If you sell in multiple currencies with Shopify Markets, the storefront shows a localized price while a naive schema block still prints shop.currency and the base price. Google fetches the page as a buyer in that market, sees USD 79.99 in the markup but €74,00 on the page, flags a price mismatch, and suppresses the rich result. Always source both price and priceCurrency from the same market-aware objects (cart.currency + the presentment price), or serve market-specific markup. This is invisible in a single-currency test and is the single most common reason a Markets store loses its price snippet.
Variants: the ProductGroup most guides never mention
A t-shirt in five sizes and three colors is not one product to Google. It is a ProductGroup of variants. Marking it as a single Product collapses the variants and can trigger "price range" ambiguities. Use ProductGroup with hasVariant, tying each variant back with variesBy:
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"name": "Merino Base Layer",
"productGroupID": "BASE-MERINO",
"variesBy": ["https://schema.org/size", "https://schema.org/color"],
"hasVariant": [
{
"@type": "Product",
"sku": "BASE-MERINO-M-BLK",
"size": "M", "color": "Black",
"offers": {
"@type": "Offer", "price": "89.00", "priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
/* one entry per variant, render with a {% for v in product.variants %} loop */
]
}
Where to inject it, and where not to
- Product schema → the product template only (
sections/main-product.liquidor a snippet it renders). Never intheme.liquid, or you emit an empty/invalid Product on every non-product page. - BreadcrumbList → global, in
theme.liquid, built fromcollection/productcontext so the path is real. - Organization → once, on the homepage, with your real
logo,sameAssocial profiles, and contact, not on every page.
Validate, then monitor. They are different jobs
The Rich Results Test tells you a single URL is eligible. It does not tell you Google is using it. For that, watch Search Console's Merchant listings and Product snippets enhancement reports over the following weeks: valid count climbing, warnings falling. If you run Google Merchant Center, the same product feed surfaces the same errors with clearer wording; fix them there and the organic markup usually clears too.
Structured data isn't a ranking hack. It is how you make Google render your price, stock, and rating instead of guessing. On Shopify the win is rarely in adding markup; it is in removing the duplicate, matching the market currency, and modeling variants the way Google's catalog expects.