# Shopify Structured Data: Product, Offer, Return Policy and Shipping Schema

Source: https://www.smartecomseo.com/blog/shopify-structured-data/  
Published: 2026-09-23  
Updated: 2026-09-29

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.

## Key takeaways

- 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:

```liquid
<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:

1. Content API for Shopping
2. Merchant Center or Search Console settings
3. Product-level markup
4. 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**.

```liquid
{%- 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.

> **returnPolicyCountry matters for Chinese brands:** Google defines `returnPolicyCountry` as the country where the product has to be sent for returns, which "can be different from the country where the product was originally shipped". If US customers return to a US warehouse, say US. If returns go back to China, say so. It should match your written policy.

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:

1. **Keep the filter** and cover returns and shipping at the organization level (above). This is the right answer for most stores.
2. **Replace the filter** with your own JSON-LD when you need per-product shipping (freight vs parcel), `variesBy`, `itemCondition` or ratings in the same entity.

If you replace it, here is a compact ProductGroup we use as a starting point:

```liquid
{%- 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.

> **Prices must match what shoppers see:** With Markets, Liquid renders prices in the visitor's currency, and Googlebot mostly crawls from the US. Make sure the price and currency in your markup match the visible price on the same URL, and that Merchant Center feed prices match both. Mismatches get products disapproved in Shopping.

## 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`:

```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](/blog/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](/blog/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

1. **Rich Results Test** on one product with variants, one without, one collection, one article and the home page.
2. **Search Console** Merchant listings and Product snippets reports, for errors and warnings across the catalog.
3. **Merchant Center** diagnostics, for price and availability mismatches.
4. **After every app install**, view source and count `application/ld+json` blocks again.

**Want your schema audited?** We check every template's structured data, merge duplicate Product blocks and add return, shipping and variant markup that matches your real policies. [See our schema service](/shopify-seo-services/schema-markup/)

## 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](/blog/shopify-technical-seo-guide/) and [Shopify SEO checklist](/blog/shopify-seo-checklist/)), then fill the schema gaps. It's a standard part of our [Shopify technical SEO](/shopify-seo-services/technical-seo/) work.

## FAQs

### 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.