Fixing JSON-LD Errors in Shopify
On a Shopify store, JSON-LD can come from four places at once: the theme, one or more apps, code a developer added, and leftovers from apps you removed long ago.
When the JSON Checker flags an error on a product, collection or blog page, the first job is working out which of those printed the faulty block, because that decides whether you fix it in Liquid, in an app setting or in the product data. This guide shows how to trace each block to its source, repair the Liquid patterns that cause most Shopify errors, and test changes safely before customers see them.
Where Shopify JSON-LD comes from
Shopify itself does not inject structured data into your storefront. Everything you see comes from the theme or from code added to it, directly or through an app.
| Source | What it usually prints | Where to look |
|---|---|---|
The theme, using the structured_data filter | Product on product pages and Article on blog posts, generated by Shopify from the object. | Sections such as main-product.liquid
and main-article.liquid in Dawn and themes built on it; search for structured_data. |
| The theme, hand-written | Organization and WebSite on the home page, sometimes BreadcrumbList, and on older themes a hand-built Product. | Search for ld+json in layout/theme.liquid,
sections/header.liquid and the snippets folder. |
| Apps via app embeds or app blocks | Product enhancements, AggregateRating and Review from review apps, FAQ, breadcrumbs, local business details. | Theme editor, then the App embeds panel; also app blocks inside product sections. |
| Code added by a developer or an older app | Anything; often a second Product or Organization block. | Custom snippets rendered from theme.liquid or product templates. |
| Tag manager or script tags | Blocks injected in the browser after the page loads. | Only visible in the rendered page, not in view source. |
The structured_data filter is convenient because Shopify keeps its output in line with
the product data, but you cannot edit what it prints. If it omits something you need, the choice is to
supplement it with a separate block that shares the same @id, or to replace it with your
own Liquid.
Tracing a block back to its file or app
The JSON Checker lists every block it found on the page. To connect each one to its source:
- Compare view source with the rendered page. Blocks present in view source (Ctrl+U) come from Liquid, so they live in the theme or an app embed. Blocks that only appear in the developer tools Elements panel were added by JavaScript.
- Search the theme code. In the admin go to Online Store, Themes, then the three-dot
menu on your theme and Edit code. Use the search to find
ld+jsonandstructured_data. Each hit is a candidate; compare its property names with the block on the page. - Toggle app embeds. In the theme editor, open App embeds and switch off one embed at a time on a copy of the theme, then preview a product page. The block that disappears belongs to that app.
- Look for app fingerprints. App output often carries an
idor class on the script element, a comment naming the app, or property values only that app would add, such as a review count. - Check for leftovers. Apps that edited theme files directly, rather than using app
embeds, can leave
{% render %}tags and snippets behind after you uninstall them. A block from an app you no longer use is almost always one of these.
The Shopify faults behind most findings
| Finding | Typical Liquid cause | Correct pattern |
|---|---|---|
| Syntax error on some products only | "name": "{{ product.title }}" breaks
when a title contains a quote, such as a 12" planter. | "name": {{ product.title | json }}
with no quotes of your own around it. |
| Control character or broken description | The description is printed with its HTML and line breaks. | {{ product.description | strip_html | json }}. The json
filter escapes line breaks. |
| Image URL invalid | image_url returns an address starting with //. | {{ product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }} |
| Invalid price | {{ variant.price | money }} prints a currency sign, and money_without_currency
can print thousands separators or a decimal comma depending on the store's money format. | {{ variant.price | divided_by: 100.0 }},
since Liquid prices are in the smallest currency unit. |
| Invalid currency | A hard-coded "GBP" on a store that sells in several currencies
through Markets. | {{ cart.currency.iso_code | json }}, so the code matches the price
being shown. |
| Availability not recognised | Printing true, "in stock" or the
inventory number. | https://schema.org/InStock or OutOfStock chosen from
variant.available. |
| Brand missing or empty | The template reads product.vendor, which is blank
on some products or holds the store name. | Fill in Vendor in the product admin, and only print
brand when it is not blank. |
| Date format warning | %z in the date filter prints +0100. | {{ article.published_at | date: '%Y-%m-%dT%H:%M:%S%:z' }} |
| Missing offers on collection pages | A snippet that prints Product is also rendered in product cards. | Only render the Product snippet from the main product section. |
A safe Liquid pattern for a custom Product block
If you replace the theme's output with your own, build every value through the json filter
and print optional properties only when they have a value. This snippet is a starting point for a store
selling in one currency with simple variants. Save it as a snippet and render it once from the main product
section.
{%- assign v = product.selected_or_first_available_variant -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"@id": {{ canonical_url | append: '#product' | json }},
"name": {{ product.title | json }},
"url": {{ canonical_url | json }},
{%- if product.featured_image %}
"image": {{ product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }},
{%- endif %}
"description": {{ product.description | strip_html | truncate: 500 | json }},
{%- if v.sku != blank %}
"sku": {{ v.sku | json }},
{%- endif %}
{%- if product.vendor != blank %}
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
{%- endif %}
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: v.url | json }},
"price": {{ v.price | divided_by: 100.0 | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": "https://schema.org/{% if v.available %}InStock{% else %}OutOfStock{% endif %}"
}
}
</script>
Each optional property ends with a comma and is followed by a property that always prints, so a skipped value never leaves a trailing comma. Rendered for a real product, the output looks like this and parses cleanly:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"@id": "https://www.example.co.uk/products/glazed-planter-12#product",
"name": "Glazed planter 12\" sage",
"url": "https://www.example.co.uk/products/glazed-planter-12",
"image": "https://www.example.co.uk/cdn/shop/files/planter-sage.jpg?width=1200",
"description": "Frost-resistant stoneware planter with drainage hole.",
"sku": "GP-12-SAGE",
"brand": { "@type": "Brand", "name": "Hollowmere Pottery" },
"offers": {
"@type": "Offer",
"url": "https://www.example.co.uk/products/glazed-planter-12?variant=40123456789",
"price": 34.0,
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock"
}
}
</script>
structured_data
output for Product and any app that prints a Product, or you will have two competing blocks. Shipping,
returns and variant groups are covered in the Product
schema guide.Apps, leftovers and theme updates
Apps are the most common reason a Shopify page carries markup nobody on the team wrote. Keep these points in mind:
- Review apps often add AggregateRating and Review either as their own Product block
or by merging into the theme's. If the theme block and the app block both describe the product, pick
one to own the Product and let the other add only ratings, using the same
@id. Many review apps have a setting for this. - SEO and schema apps usually offer toggles per type. If you turn on their Product markup, turn off the theme's, and the other way round.
- Uninstalled apps that edited theme files leave code behind. Search the theme for the app's name and for snippets you do not recognise, and remove the render tags as well as the snippet files.
- Theme updates install a fresh copy of the theme. Custom edits inside theme files
do not carry over automatically, so a fix made in
main-product.liquidcan vanish. Keep custom markup in its own snippet with a clear name, and note every file you changed.
How to decide which of two competing blocks should win is covered in the competing schemas guide. This page is about finding and repairing them on Shopify.
The fix, step by step
Step 1
Count the blocks on each page type
Run the JSON Checker on the home page, one collection, one product, one blog post and one standard page. For each, note how many JSON-LD blocks appear, which types each contains and which have errors. Use a product with a quote in its title, a sale price and an out-of-stock variant, because a plain product often hides the faults that affect the rest of the catalogue.
Step 2
Trace each block to a theme file or app
Open Edit code on the theme and search for ld+json and structured_data,
then match each result to a block on the page. For anything left over, open App embeds in the theme editor
and switch embeds off one at a time on a copy of the theme. Blocks missing from view source but present
in the rendered page come from scripts or a tag manager.
Step 3
Switch off the copies you do not need
Decide which source owns each type, for example the theme for Product and Article and the review app for ratings only. Turn off duplicate output in app settings, disable unused app embeds, and remove render tags left by apps you have uninstalled. Aim for one block per entity on each page, so that every error you fix afterwards is in code or settings that actually count.
Step 4
Repair the Liquid that prints bad values
In the blocks you keep, pass every text value through the json filter and
remove your own quotes around it. Strip HTML from descriptions, add prepend: 'https:' after
image_url, print prices with divided_by: 100.0 rather than a money filter,
and read the currency from cart.currency.iso_code. Wrap optional properties in conditions
so blank values are left out of the block instead of being printed empty.
Step 5
Fill the product data the markup reads
Many findings are data gaps rather than code faults. Export products and look for blank Vendor, SKU, barcode and featured image fields, then complete them in bulk through a CSV import or the bulk editor. Only use barcodes that really belong to the product. Where the theme reads metafields for extra properties, make sure those metafields are filled for every product that uses the template.
Step 6
Test on a copy theme before publishing
Make all edits on a duplicate of the live theme and use its preview link to run the JSON Checker and the Rich Results Test on each page type. Only publish the copy once every block parses and the required fields are present. Afterwards, request indexing for a few key products in Search Console and use Validate fix on the Product snippets and Merchant listings reports.
Variants, Markets and other edge cases
- Variant URLs. When a shopper picks a variant, the URL gains
?variant=and the visible price may change. The markup rendered by Liquid reflects the variant selected when the page was served, so link the offer to the variant URL it describes. - Many variants. For products with several sizes or colours, a single Offer for the first available variant is acceptable but incomplete. Describing each variant needs a ProductGroup structure; plan that as a separate piece of work.
- Markets and currencies. With several markets, prices and currency change with the shopper's country. Reading price and currency from the same Liquid objects keeps them consistent; a hard-coded currency is the usual cause of mismatches.
- Password-protected stores. While the storefront password is on, crawlers cannot reach the pages at all. Test through theme preview, and expect Search Console reports only after launch.
- Headless storefronts. If the store runs on Hydrogen or another framework, the markup lives in that codebase, not in Liquid, and the theme advice here does not apply.
1. Does Shopify add Product schema automatically?
Shopify itself does not inject structured data into the storefront, but most current themes, including Dawn and themes built on it, print Product markup on product pages using the structured_data Liquid filter. Older and third-party themes may hand-write their own block instead. Apps can add more on top. View source on a product page and search for application/ld+json to see exactly what your store prints and how many blocks there are.
2. Why does my Shopify product schema break only on some products?
Almost always because the template inserts product text without escaping it. A title or description containing a double quote, such as a measurement in inches, ends the string early, and a description with line breaks adds control characters. Products without those characters parse fine, which makes the problem look random. Passing every text value through the Liquid json filter, instead of wrapping it in quotes yourself, fixes all of them at once.
3. How do I stop two Product blocks appearing on my product pages?
Find both sources first: typically the theme structured_data output and an SEO or review app. Then decide which one owns the Product and disable the other, either with the app settings or by removing the theme output on a copy of the theme. If a review app only needs to add ratings, check whether it can merge into the existing block by using the same @id instead of printing a separate Product.
4. Why is the price in my Shopify schema wrong or invalid?
Liquid stores prices in the smallest currency unit, so a price of 3400 means 34.00 pounds. Templates that print the money filter add a currency symbol, and money_without_currency follows the store money format, which can add thousands separators or a decimal comma. Divide the price by 100.0 to print a plain number, and read the currency from cart.currency.iso_code so it always matches the price and currency shown to the shopper on the page.
5. Will a theme update remove my JSON-LD fixes?
It can. Updating a theme from the theme store installs a new version, and edits you made inside its files are not merged in automatically. Keep custom markup in a separate, clearly named snippet so it is easy to copy across, record which files you changed, and re-run the JSON Checker on each page type after any update. The same applies when you switch to a different theme altogether, since each theme handles structured data differently.
6. I removed an app but its schema is still on my pages. Why?
Apps that work through app embeds stop printing when they are uninstalled, but older apps often edited theme files directly, adding render tags and snippets that stay behind. Those leftovers may still print markup, sometimes with stale or broken data. Search the theme code for the app name and for snippets you do not recognise, remove both the render tag and the snippet file on a copy of the theme, then test before publishing it.
7. Can I test JSON-LD changes without affecting my live store?
Yes. Duplicate the live theme from the Themes page, make your edits in the copy and open its preview. The preview link lets you view source and run the JSON Checker on real product pages without customers seeing anything. Google testing tools can only fetch public URLs, so for them paste the rendered code rather than the address. Publish the copy only once every page type passes, and keep the old theme as a fallback.
The JSON Checker parses every JSON-LD block on your page, validates the syntax, checks each type against schema.org, verifies required fields, detects conflicting types and runs the result against Google rich result criteria.
Open JSON-LD Fixes โ