The product pages lost their star ratings and prices in search results. Click-through dropped by a third. Three weeks later, someone traced it to a front-end refactor that moved the review widget, which also changed where the aggregateRating was rendered. The JSON-LD still existed. It just had "ratingValue": "".

No error, no failed build, no alert. Structured data fails silently by design.

TL;DR

  • JSON-LD breaks in boring ways: invalid JSON, empty values, a missing required field, or the whole block vanishing in one template.
  • Validators are great for one URL, but nobody runs them weekly on every template.
  • A small script can parse every JSON-LD block, check the fields you depend on and diff results between runs.
  • My opinion: monitor the fields you rely on for rich results, not schema validity in general. A perfectly valid block with an empty price is still broken.

The common failure modes

  1. Invalid JSON: a trailing comma, an unescaped quote in a product name, a template that outputs undefined.
  2. Empty or placeholder values: "price": "", "ratingValue": "{{rating}}", "image": null.
  3. Block missing on some templates: present on product pages with reviews, missing when there are none.
  4. Type drift: "price": "19,99 €" instead of "19.99" plus a currency.
  5. Duplicated or conflicting blocks: a plugin and the theme both output Product, with different prices.

A checker you can run anywhere

# pip install requests beautifulsoup4 import json, sys, requests from bs4 import BeautifulSoup REQUIRED = { "Product": ["name", "offers.price", "offers.priceCurrency"], "Article": ["headline", "datePublished", "author"], "BreadcrumbList": ["itemListElement"], "FAQPage": ["mainEntity"], } def get(d, path): for part in path.split("."): if isinstance(d, list): d = d[0] if d else None if not isinstance(d, dict): return None d = d.get(part) return d def blocks(html): soup = BeautifulSoup(html, "html.parser") for tag in soup.find_all("script", type="application/ld+json"): raw = tag.string or tag.get_text() try: data = json.loads(raw) except json.JSONDecodeError as e: yield {"_error": f"invalid JSON: {e.msg} at char {e.pos}"} continue items = data.get("@graph", [data]) if isinstance(data, dict) else data for item in items: yield item def check(url): html = requests.get(url, timeout=20, headers={"User-Agent": "Mozilla/5.0"}).text problems, types = [], [] for b in blocks(html): if "_error" in b: problems.append(b["_error"]); continue t = b.get("@type") t = t[0] if isinstance(t, list) else t types.append(t) for field in REQUIRED.get(t, []): v = get(b, field) if v in (None, "", [], {}) or (isinstance(v, str) and "{{" in v): problems.append(f"{t}: missing or empty {field}") return types, problems for url in sys.argv[1:]: types, problems = check(url) status = "OK " if not problems else "BAD" print(f"{status} {url} types={types}") for p in problems: print(f" - {p}") 
python jsonld_check.py https://example.com/product/42 https://example.com/blog/some-post 

Output:

BAD https://example.com/product/42 types=['Product', 'BreadcrumbList'] - Product: missing or empty offers.price OK https://example.com/blog/some-post types=['Article'] 

Adjust REQUIRED to match the rich results you actually care about. Search engines' documentation lists required and recommended properties per type.

Pick URLs per template, not per page

You don't need to check 50,000 product pages. Pick 2 or 3 URLs per template and per state:

  • product with reviews, product without reviews, out-of-stock product
  • article with an author, article from a guest or a staff account
  • category page, homepage

Template bugs hit every page of that template at once, so a small sample catches them. A sitemap is a good source of examples for each section, and How to find orphan pages by comparing your sitemap to internal links shows how to pull URLs from one.

Diff between runs

Validity isn't the only signal. A block that silently changes type, or loses a field you had last week, matters too. Save each run's results as JSON and compare:

python jsonld_check.py $(cat sample-urls.txt) > today.txt diff yesterday.txt today.txt && echo "no changes" || echo "structured data changed" mv today.txt yesterday.txt 

It's the same idea as tracking title tag changes, described in How to catch title tag changes after a deploy: you care less about what the tag says than about whether it changed without anyone meaning it to.

JavaScript-injected JSON-LD

If your structured data is injected by a tag manager or client-side code, requests won't see it. Either render the page with a headless browser before parsing, or, better, move the JSON-LD into the server-rendered HTML. The raw-vs-rendered diff from the previous post will show you which case you're in.

Run it where it hurts least

  • In CI, against a preview deployment, for the template sample. Fail the build on invalid JSON or an empty required field.
  • On a schedule against production, because CMS content and plugins change without deploys.

Dedicated monitors like seotto.com watch on-page tags across many sites without you maintaining scripts, but the script above is enough to stop the "three weeks before anyone noticed" scenario on a single site.

My take

Structured data is code that only machines read, so humans never notice when it breaks. That makes it a perfect candidate for automated checks and a terrible candidate for manual QA. Check the fields that earn you rich results, check them per template, and check them every day.

Over to you

Have you lost rich results to a silent structured data break? I'd like to hear what broke it, especially if it was a plugin update or a CMS change rather than a code deploy.