Shopify Structured Data: Product, Offer, Return Policy and Shipping Schema
What Shopify themes output as structured data, what Google wants for merchant listings, and how to add return policy, shipping and variant markup in Liquid.

- Shopify's structured_data filter outputs Product for products without variants and ProductGroup for products with variants, with price, currency and availability per variant.
- On the Dawn output we checked, there was no return policy, shipping, item condition, ratings or variesBy. Those are the gaps to fill.
- Google recommends declaring your return and shipping policy once, under OnlineStore or Organization markup, rather than repeating it on every product.
- Merchant Center and Search Console settings override product markup, which overrides organization markup. Keep all of them consistent.
- One Product entity per page. Review apps that add a second Product block split your data; pull ratings from the reviews.rating metafields instead.
Shopify structured data is one of those areas where the platform gets you most of the way and then stops. Every modern theme outputs Product markup. Almost none output the return policy and shipping details Google asks for in merchant listings, and many stores end up with two or three conflicting Product blocks once review and SEO apps join in.
This guide covers what Shopify outputs by default, what Google wants, and how to fill the gaps in Liquid. We read Shopify's Liquid documentation and Google Search Central's merchant listing, return policy, shipping policy and product variant documentation, and pulled the JSON-LD from Shopify's Dawn demo store, in September 2026.
What Shopify outputs by default#
Themes built on Dawn render product markup with one line:
<script type="application/ld+json">
{{ product | structured_data }}
</script>Shopify's docs say the filter works on product and article objects. Products "are output as a schema.org Product if they have no variants, and a ProductGroup if they have one or more variants." Articles become Article.
Here's what the Dawn demo product page contained when we pulled it:
| Property | Present | Notes |
|---|---|---|
@type ProductGroup with hasVariant | Yes | One nested Product per variant |
name, description, url, brand | Yes | Brand comes from the product vendor |
productGroupID | Yes | The product ID |
category | Yes, but empty | Filled from the product category when set |
Variant sku, gtin, image, name | Yes | GTIN from the barcode field |
Offer price, priceCurrency, availability, url | Yes | Per variant, with ?variant= URLs |
variesBy | No | Google recommends it for variant groups |
Variant color / size | No | Recommended by Google |
itemCondition | No | Recommended for merchant listings |
hasMerchantReturnPolicy, shippingDetails | No | Recommended for merchant listings |
aggregateRating, review | No | Usually added by a review app |
BreadcrumbList | No | Not on the product page we checked |
Dawn also printed an Organization block (name, logo, sameAs, url) on the pages we checked. Your theme may output more or less, so check before you change anything: view source and search for application/ld+json, or run the page through Google's Rich Results Test.
What Google wants for merchant listings#
Google's merchant listing documentation has two layers.
Required: name, image and offers on the Product, and price (greater than zero) and priceCurrency on the Offer. Shopify's default covers these.
Recommended: availability, itemCondition, shippingDetails, hasMerchantReturnPolicy, identifiers like gtin, brand, and review data. These are the fields that let Google show "free returns" or delivery costs next to your products.
For variants, Google's product variant guide recommends productGroupID, variesBy and hasVariant on the ProductGroup, and properties like color and size on each variant. Shopify stores are what Google calls "single-page" variant sites: one canonical URL for the group, with ?variant= preselecting each option.
Return policy and shipping: do it once, at the organization level#
You don't need to repeat your return policy on 5,000 products. Google's return policy documentation says a standard policy that applies to most or all products can be nested under Organization with hasMerchantReturnPolicy, and shipping works the same way with hasShippingService. Google recommends placing each on a single page rather than every page, and recommends the OnlineStore subtype for ecommerce sites.
The precedence order matters, because it tells you which source wins when they disagree. From strongest to weakest, per Google's documentation:
- Content API for Shopping
- Merchant Center or Search Console settings
- Product-level markup
- Organization-level markup
So if you sync products to Merchant Center (for example through Shopify's Google & YouTube app), set your return and shipping policies there too. The markup is the fallback for everything Merchant Center doesn't cover, and it's what other systems reading your pages see.
Liquid: an OnlineStore block with returns and shipping#
This goes in theme.liquid, output only on the home page. It reads the policy numbers from shop metafields, so your team can change them in the admin without touching code. Create the metafields first under Settings > Custom data > Shop.
{%- if request.page_type == 'index' -%}
{%- liquid
assign return_days = shop.metafields.custom.return_days.value | default: 30
assign return_country = shop.metafields.custom.return_country.value | default: 'US'
-%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "OnlineStore",
"name": {{ shop.name | json }},
"url": {{ request.origin | json }},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": ["US", "CA"],
"returnPolicyCountry": {{ return_country | json }},
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": {{ return_days | json }},
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/ReturnShippingFees",
"merchantReturnLink": {{ shop.refund_policy.url | prepend: request.origin | json }}
},
"hasShippingService": {
"@type": "ShippingService",
"name": "Standard freight delivery",
"shippingConditions": [{
"@type": "ShippingConditions",
"shippingDestination": { "@type": "DefinedRegion", "addressCountry": "US" },
"shippingRate": { "@type": "MonetaryAmount", "value": 0, "currency": "USD" },
"transitTime": {
"@type": "ServicePeriod",
"duration": { "@type": "QuantitativeValue", "minValue": 5, "maxValue": 10, "unitCode": "DAY" }
}
}]
}
}
</script>
{%- endif -%}Adjust the countries, fees and transit times to your real policy. If your theme already prints an Organization block, merge the two into one entity instead of adding a second.
For high-ticket stores, use the fields that reflect how you really ship: freight delivery with longer transit times, return shipping fees or a restockingFee on large items. Accurate beats attractive. Markup that promises free returns your policy doesn't offer is a mismatch waiting to be found.
Product-level markup: when the default isn't enough#
The structured_data filter returns a finished JSON object, so you can't add properties to it. You have two choices:
- Keep the filter and cover returns and shipping at the organization level (above). This is the right answer for most stores.
- Replace the filter with your own JSON-LD when you need per-product shipping (freight vs parcel),
variesBy,itemConditionor ratings in the same entity.
If you replace it, here is a compact ProductGroup we use as a starting point:
{%- liquid
assign rating = product.metafields.reviews.rating.value
assign rating_count = product.metafields.reviews.rating_count.value
-%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 60 | json }},
"url": {{ product.url | prepend: request.origin | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"productGroupID": {{ product.id | json }},
{%- if rating and rating_count > 0 %}
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ rating.rating | json }},
"bestRating": {{ rating.scale_max | json }},
"ratingCount": {{ rating_count | json }}
},
{%- endif %}
"hasVariant": [
{%- for variant in product.variants %}
{
"@type": "Product",
"name": {{ product.title | append: ' - ' | append: variant.title | json }},
"sku": {{ variant.sku | json }},
{%- if variant.barcode != blank %}"gtin": {{ variant.barcode | json }},{% endif %}
"image": {{ variant.image | default: product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }},
"offers": {
"@type": "Offer",
"url": {{ variant.url | prepend: request.origin | json }},
"price": {{ variant.price | divided_by: 100.0 | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"itemCondition": "https://schema.org/NewCondition",
"availability": "https://schema.org/{% if variant.available %}InStock{% else %}OutOfStock{% endif %}"
}
}{% unless forloop.last %},{% endunless %}
{%- endfor %}
]
}
</script>Add variesBy and per-variant color or size if your options map cleanly to them; Google expects schema.org values such as https://schema.org/color, so an option like "Wood finish" doesn't fit. If you use this block, remove the structured_data line from the product section, or you'll ship two Product entities.
The reviews.rating and reviews.rating_count metafields are Shopify standard definitions that review apps are meant to write to. Check that yours does before relying on them.
One Product entity per page#
This is the most common structured data problem we find on Shopify, and it's usually caused by apps:
- The theme outputs a ProductGroup.
- The review app outputs a second Product with only
aggregateRating. - An SEO app outputs a third, sometimes with a different price or currency.
Google has to reconcile them, and the Rich Results Test often shows duplicate or partial items. Pick one source of truth. Either let one app own product markup and remove the theme's line, or keep the theme's markup, turn off the apps' schema output, and bring ratings in through the metafields.
Other types worth having#
| Type | Where | Why |
|---|---|---|
OnlineStore / Organization | Home page | Name, logo, sameAs, returns and shipping |
BreadcrumbList | Products and collections | Shows hierarchy that Shopify URLs don't |
Article | Blog posts | Dawn applies the same structured_data filter to articles |
ItemList | Collections (optional) | Some teams add it; Google doesn't show a rich result for it on category pages |
FAQPage | Pages with real FAQs | Google limits FAQ rich results to authoritative government and health sites, so treat it as optional |
BreadcrumbList for collections#
Shopify URLs don't show hierarchy, so breadcrumbs do that job. A minimal collection-page version for theme.liquid:
{%- if template.name == 'collection' -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home",
"item": {{ request.origin | append: routes.root_url | json }} },
{ "@type": "ListItem", "position": 2, "name": {{ collection.title | json }},
"item": {{ collection.url | prepend: request.origin | json }} }
]
}
</script>
{%- endif -%}On product pages, use the same primary-collection logic as your visible breadcrumb, so the markup and the page agree. The pattern is in Shopify URL structure.
For AI assistants, markup helps less than you might hope. Shopify's AI channels read product data from your admin (category, metafields, descriptions), and ChatGPT or Perplexity read the page itself. Clean HTML with specs in text matters as much as JSON-LD. More in how to rank in ChatGPT.
Errors we see most on Shopify catalogs#
| Report message or symptom | Usual cause on Shopify | Fix |
|---|---|---|
| Missing field "hasMerchantReturnPolicy" | Theme default has no return data | Organization-level return policy |
| Missing field "shippingDetails" | Same | Organization-level hasShippingService |
| Invalid or zero price | "Request a quote" or sample products priced at 0 | Don't output an Offer for quote-only products |
| Invalid GTIN | Internal codes typed into the barcode field | Clear the barcode or enter the real GTIN |
| Two Product items on one URL | Review or SEO app adding its own block | One source of truth |
| Price mismatch in Merchant Center | Market currency or sale price differs between feed and page | Check the page price per market |
Two of these hit our ideal clients hardest. High-ticket stores often sell some items by quote, and a price of zero fails merchant listing requirements; leave the Offer out for those products and make the page's enquiry path clear instead. Chinese brands moving from Amazon often carry internal FNSKU or factory codes in the barcode field, which then appear as invalid GTINs across the catalog.
How to test#
- Rich Results Test on one product with variants, one without, one collection, one article and the home page.
- Search Console Merchant listings and Product snippets reports, for errors and warnings across the catalog.
- Merchant Center diagnostics, for price and availability mismatches.
- After every app install, view source and count
application/ld+jsonblocks again.
Where this fits#
Structured data won't rank a page on its own. It makes sure Google reads your price, stock, returns and shipping correctly, which is what merchant listings and Shopping surfaces run on. Get crawl and canonicals right first (the Shopify technical SEO guide and Shopify SEO checklist), then fill the schema gaps. It's a standard part of our Shopify technical SEO work.
Questions people ask about this
Does Shopify add structured data automatically?
Most themes do. Dawn and themes built on it output {{ product | structured_data }}, which becomes a Product or a ProductGroup with variants, including brand, SKU, GTIN, price, currency and availability. On the Dawn output we checked there was no return policy, shipping details, item condition, ratings or breadcrumb markup, so those usually need adding in the theme.
How do I add return policy schema to Shopify?
The simplest way is organization-level markup: an OnlineStore or Organization JSON-LD block on your home page with hasMerchantReturnPolicy, including applicableCountry, returnPolicyCategory, merchantReturnDays, returnMethod and returnFees. Google recommends this over repeating the policy on every product. If you use Merchant Center, set the same policy there, because Merchant Center settings take precedence.
Do I need shippingDetails on every Shopify product?
No. Google supports a shipping policy declared once with hasShippingService under Organization or OnlineStore. Product-level shippingDetails are only needed when products ship differently, for example freight for large items and parcel for accessories. Merchant Center shipping settings override both, so keep them consistent with your markup and your written shipping policy.
Why does my Shopify product have two Product schema blocks?
Usually because a review or SEO app adds its own Product markup alongside the theme's. Google then sees duplicate or partial items. Choose one source: remove the theme's structured_data line and let one app own it, or keep the theme markup, turn off app schema output, and pull ratings from the reviews.rating and reviews.rating_count metafields.
Does structured data help Shopify stores appear in AI search?
Somewhat. Accurate markup helps systems read price, availability and policies, but AI assistants also read the visible page, and Shopify's AI channels use product data from your admin, such as categories and metafields. Clear specs in the page text, complete product data and crawlable pages matter at least as much as JSON-LD.
Want this done on your store?
Clean Product, ProductGroup, Offer, return, shipping, Organization and breadcrumb markup for Shopify, aligned with your Merchant Center feed for free listings.
More from Technical SEO

The Shopify Apps Slowing Down Your Store and Quietly Hurting SEO (Plus a 20-Minute Audit)
Shopify apps slow down your store, leave code behind after you uninstall them, and sometimes change what Google sees. This 20-minute audit shows where to look and what to remove.

Crawled – Currently Not Indexed on Shopify: Why Google Skips Your Pages
Crawled – currently not indexed means Google fetched a page and chose to leave it out. On Shopify most of those URLs are harmless; here is how to find the few that cost you sales and fix them.

Shopify Speed Optimization: Fixing LCP on Collection Pages Without Killing Apps
Why Shopify collection pages fail LCP, how to find the real LCP element, and the fixes in order: image loading, animations, app scripts and Liquid render time.