An LLM can draft JSON-LD in seconds, and it will confidently put a price on a Product, invent a property called inStock, and write a date as "9 October 2026". The fix is not a better prompt alone. It is a validator that checks the output against the schema.org vocabulary, a few Google rules, and the visible page. This post gives you that validator in Python, the demo pages I ran it on, and the real output.
Status: the Google statements below come from Search Central pages (intro to structured data, general guidelines, Article, Product snippet, and the documentation changelog) fetched and read as full text on 2026-10-09. The schema.org vocabulary file was downloaded from schema.org on the same day. I did not run Google's Rich Results Test or the Schema Markup Validator, which are web tools, so I make no claim about what they report on these pages. The demo pages and their "LLM mistakes" are invented by me, not captured from a model. The prompt is an untested template.
Should an LLM write your JSON-LD at all?
Often not. Decide by where the page's data comes from.
| Your page | Better approach |
|---|---|
| Product or listing pages built from a database or CMS fields | Generate JSON-LD in code from those fields. A model adds nothing and can invent a rating. |
| Articles with a known title, author and dates | Same: template it from front matter. |
| Hand-built or legacy pages with no structured fields | LLM draft from the page's visible text, then validate and review. |
| Pages where you want to extract entities (events, how-to steps) from prose | LLM draft, strict validation, human review. |
If you use a model, give it facts and forbid invention. If your pipeline already has a structured-output feature, constrain the shape; see structured outputs and JSON mode. A shape constraint helps with syntax. It does not know schema.org, which is why the validator below exists.
What do schema.org and Google say you need?
Facts from the pages as read on 2026-10-09:
- Format. Google recommends JSON-LD; Microdata and RDFa are also supported and "equally fine" if the markup is valid.
- Article, NewsArticle, BlogPosting. No required properties. Recommended:
author(withnameandurlorsameAs),datePublished,dateModified,headline,image. Dates in ISO 8601, with a timezone preferred. - Product snippets.
nameis required, plus at least one ofreview,aggregateRatingoroffers. AnOfferneedsprice(orpriceSpecification.price).priceCurrencyis recommended for product snippets and required for merchant listing experiences, and Google says to always provide it, as a three-letter ISO 4217 code.availabilitytakes a schema.org ItemAvailability value such asInStock. - Content must match the page. Don't mark up content that isn't visible to readers, and don't mark up misleading content such as fake reviews. Violations can lead to a structured data manual action, which affects rich result eligibility and, per Google, not how the page ranks in web search.
- No guarantee. Correct markup does not guarantee a rich result.
- FAQ. The FAQ rich result feature was deprecated and stopped appearing from 7 May 2026, and its documentation was removed on 15 June 2026, per the changelog.
Everything schema.org itself says about types and properties is in the vocabulary file. That file is what lets a script answer "is inStock a real property?" without guessing.
What should the generation prompt say?
Untested template. The key lines forbid invention and limit the types.
Produce one JSON-LD object for the page below. Output JSON only, no markdown fences.
Use "@context": "https://schema.org" and only these types: [ALLOWED_TYPES].
Use only values present in PAGE FACTS or PAGE TEXT. If a value is absent, omit the property.
Never invent ratings, review counts, prices, dates, authors or availability.
Use schema.org property names exactly. Dates in ISO 8601 with a timezone offset.
Prices as numbers or numeric strings without currency symbols; currency as a 3-letter ISO 4217 code.
availability as a schema.org URL, for example https://schema.org/InStock.
PAGE FACTS (JSON): [FACTS]
PAGE TEXT (visible): [TEXT]
Two things matter more than the wording. Pass the same visible text the reader sees, because that is what the validator will compare against. And pass an explicit allowed-types list, so the model does not decide to add a Review because the page felt reviewy.
What does the validator check?
It runs five layers on every JSON-LD block it finds in an HTML file:
- JSON syntax, with line and column for the error.
- schema.org vocabulary: each
@typeexists (with a "did you mean" hint), each property exists, and each property is defined for the node's type or one of its parent types. This is the layer that catchespriceon aProduct. - Value formats: ISO 8601 dates (a warning when a time has no timezone), a three-letter currency code, and
availabilitybeing a real ItemAvailability value. - Google rules from the pages above: Product needs
nameand one of review, aggregateRating or offers; Offer needs a price; Article recommended properties; a note on FAQPage. - Visible-text match: headline, product name, price, rating value and review count must appear in the page's visible text. That is a mechanical version of "don't mark up what readers can't see".
Standard library only. Download the vocabulary first:
curl -sS -L -o schemaorg-current-https.jsonld https://schema.org/version/latest/schemaorg-current-https.jsonld
Then save this as jsonld_check.py:
import difflib
import json
import re
import sys
from html.parser import HTMLParser
ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$")
def as_list(x):
return x if isinstance(x, list) else ([] if x is None else [x])
def load_vocab(path):
graph = json.load(open(path, encoding="utf-8"))["@graph"]
classes, parents, props, individuals = set(), {}, {}, {}
for n in graph:
nid = n.get("@id", "")
if not nid.startswith("schema:"):
continue
name = nid[7:]
types = as_list(n.get("@type"))
if "rdfs:Class" in types:
classes.add(name)
parents[name] = [p["@id"][7:] for p in as_list(n.get("rdfs:subClassOf")) if p["@id"].startswith("schema:")]
elif "rdf:Property" in types:
props[name] = {
"domain": {d["@id"][7:] for d in as_list(n.get("schema:domainIncludes"))},
"range": {r["@id"][7:] for r in as_list(n.get("schema:rangeIncludes"))},
}
else:
for t in types:
if t.startswith("schema:"):
individuals.setdefault(t[7:], set()).add(name)
return classes, parents, props, individuals
def ancestors(cls, parents):
seen, stack = set(), [cls]
while stack:
c = stack.pop()
if c not in seen:
seen.add(c)
stack.extend(parents.get(c, []))
return seen
class Page(HTMLParser):
def __init__(self):
super().__init__()
self.blocks, self.text, self._in_ld, self._skip = [], [], False, 0
def handle_starttag(self, tag, attrs):
if tag == "script":
self._in_ld = dict(attrs).get("type") == "application/ld+json"
self._skip += 1
if self._in_ld:
self.blocks.append("")
elif tag in ("style", "noscript"):
self._skip += 1
def handle_endtag(self, tag):
if tag in ("script", "style", "noscript"):
self._skip -= 1
self._in_ld = False
def handle_data(self, d):
if self._in_ld:
self.blocks[-1] += d
elif not self._skip:
self.text.append(d)
def norm(s):
return re.sub(r"\s+", " ", str(s)).strip().lower()
def walk(node, path=""):
if isinstance(node, dict):
yield path, node
for k, v in node.items():
if not k.startswith("@"):
yield from walk(v, f"{path}.{k}" if path else k)
elif isinstance(node, list):
for v in node:
yield from walk(v, path)
def check_node(node, path, vocab, issues):
classes, parents, props, individuals = vocab
types = [t for t in as_list(node.get("@type")) if isinstance(t, str)]
where = path or "(root)"
known = []
for t in types:
if t in classes:
known.append(t)
else:
near = difflib.get_close_matches(t, classes, n=1, cutoff=0.7)
hint = f" (did you mean {near[0]}?)" if near else ""
issues.append(f"ERROR {where}: unknown type '{t}'{hint}")
allowed = set()
for t in known:
allowed |= ancestors(t, parents)
for key, val in node.items():
if key.startswith("@") or not known:
continue
if key not in props:
near = difflib.get_close_matches(key, props, n=1, cutoff=0.75)
hint = f" (did you mean {near[0]}?)" if near else ""
issues.append(f"ERROR {where}: '{key}' is not a schema.org property{hint}")
elif not (props[key]["domain"] & allowed):
issues.append(f"ERROR {where}: '{key}' is not defined for {'/'.join(known)} (its domain: {', '.join(sorted(props[key]['domain']))[:60]})")
else:
for v in as_list(val):
if isinstance(v, dict) and v.get("@type") in classes:
rng = props[key]["range"]
if not (ancestors(v["@type"], parents) & rng):
issues.append(f"WARN {where}.{key}: nested {v['@type']} where schema.org expects {', '.join(sorted(rng))[:60]}")
if key == "availability" and isinstance(val, str):
short = val.rsplit("/", 1)[-1]
if short not in individuals.get("ItemAvailability", set()):
issues.append(f"ERROR {where}: availability '{val}' is not an ItemAvailability value")
for key in ("datePublished", "dateModified", "validThrough", "priceValidUntil"):
v = node.get(key)
if isinstance(v, str):
if not ISO_DATE.match(v):
issues.append(f"ERROR {where}.{key}: '{v}' is not ISO 8601")
elif "T" in v and not re.search(r"(Z|[+-]\d{2}:\d{2})$", v):
issues.append(f"WARN {where}.{key}: no timezone (Google recommends one)")
def google_rules(node, path, issues):
"""Rules read on Google Search Central pages on 2026-10-09."""
types = as_list(node.get("@type"))
where = path or "(root)"
if "Product" in types:
if not node.get("name"):
issues.append(f"RULE {where}: Product snippets require name")
if not any(node.get(k) for k in ("review", "aggregateRating", "offers")):
issues.append(f"RULE {where}: Product needs one of review, aggregateRating or offers")
if "Offer" in types:
if "price" not in node and not isinstance(node.get("priceSpecification"), dict):
issues.append(f"RULE {where}: Offer requires price")
if "priceCurrency" not in node and "priceSpecification" not in node:
issues.append(f"WARN {where}: Offer without priceCurrency (required for merchant listings)")
elif isinstance(node.get("priceCurrency"), str) and not re.fullmatch(r"[A-Z]{3}", node["priceCurrency"]):
issues.append(f"ERROR {where}: priceCurrency '{node['priceCurrency']}' is not a 3-letter ISO 4217 code")
if any(t in types for t in ("Article", "BlogPosting", "NewsArticle")):
for k in ("headline", "image", "author", "datePublished", "dateModified"):
if k not in node:
issues.append(f"INFO {where}: recommended property '{k}' missing (none are required)")
if "FAQPage" in types:
issues.append(f"INFO {where}: Google's changelog says FAQ rich results stopped showing on 2026-05-07")
def visible_checks(node, path, visible, issues):
where = path or "(root)"
vis_nocomma = visible.replace(",", "")
fields = []
if "headline" in node:
fields.append(("headline", node["headline"]))
if "name" in node and any(t in as_list(node.get("@type")) for t in ("Product", "Person", "Organization")):
fields.append(("name", node["name"]))
if "price" in node:
fields.append(("price", node["price"]))
for k in ("ratingValue", "reviewCount", "ratingCount"):
if k in node:
fields.append((k, node[k]))
for k, v in fields:
s = norm(v)
if not s:
continue
if k in ("price", "ratingValue", "reviewCount", "ratingCount"):
num = s.rstrip("0").rstrip(".") if "." in s else s
if num not in vis_nocomma and s not in vis_nocomma:
issues.append(f"ERROR {where}.{k}: '{v}' not found in visible page text")
elif s not in visible:
issues.append(f"ERROR {where}.{k}: '{v}' not found in visible page text")
def check_page(path, vocab):
p = Page()
p.feed(open(path, encoding="utf-8").read())
visible = norm(" ".join(p.text))
issues = []
if not p.blocks:
issues.append("ERROR no application/ld+json block found")
for i, raw in enumerate(p.blocks, 1):
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
issues.append(f"ERROR block {i}: invalid JSON ({e.msg} at line {e.lineno} col {e.colno})")
continue
for item in as_list(data):
ctx = item.get("@context") if isinstance(item, dict) else None
if ctx not in ("https://schema.org", "http://schema.org", "https://schema.org/", "http://schema.org/"):
issues.append(f"ERROR block {i}: @context is {ctx!r}, expected https://schema.org")
roots = item.get("@graph", [item]) if isinstance(item, dict) else []
for root in roots:
for npath, node in walk(root):
check_node(node, npath, vocab, issues)
google_rules(node, npath, issues)
visible_checks(node, npath, visible, issues)
return issues
if __name__ == "__main__":
vocab = load_vocab(sys.argv[1])
print(f"vocabulary: {len(vocab[0])} classes, {len(vocab[2])} properties")
errors = 0
for page in sys.argv[2:]:
issues = check_page(page, vocab)
errors += sum(i.startswith(("ERROR", "RULE")) for i in issues)
print(f"\n{page.rsplit('/', 1)[-1]}: {'clean' if not issues else str(len(issues)) + ' finding(s)'}")
for i in issues:
print(" ", i)
sys.exit(1 if errors else 0)
Usage: python3 jsonld_check.py schemaorg-current-https.jsonld page1.html page2.html. The exit code is 1 when any ERROR or RULE finding exists, so it can gate a CI job.
What does it find on real-looking mistakes?
Each demo file is a minimal HTML page: a title, one or more application/ld+json scripts, and a body with an h1 and one visible line. The store is fictional.
good_product.html is built the way a template would build it:
<script type="application/ld+json">
{"@context": "https://schema.org", "@type": "Product",
"name": "Indigo Block Print Cotton Kurta",
"image": ["https://example.com/img/indigo-kurta.webp"],
"sku": "KL-IND-M",
"brand": {"@type": "Brand", "name": "Kala Looms"},
"offers": {"@type": "Offer", "price": "2499.00", "priceCurrency": "INR",
"availability": "https://schema.org/InStock",
"url": "https://example.com/kurtas/indigo-block-print"}}
</script>
<!-- body: h1 "Indigo Block Print Cotton Kurta"; p "Price: ₹2,499. In stock. Ships in 3 to 5 working days." -->
bad_product.html has the same body and this block, with mistakes I wrote to look like typical model errors (hand-written, not model output):
<script type="application/ld+json">
{"@context": "https://schema.org", "@type": "Product",
"name": "Indigo Block Print Cotton Kurta",
"price": "2499", "inStock": true, "rating": 4.7,
"brand": "Kala Looms",
"aggregateRating": {"@type": "AggregateRating", "ratingValue": 4.7, "reviewCount": 212},
"offers": {"@type": "Offer", "price": "2499", "priceCurrency": "Rs",
"availability": "In stock"}}
</script>
bad_article.html (body: h1 "How we pick indigo dyes", p "By Asha Rao.") has two blocks, a mistyped type with a non-ISO date and a missing timezone, then an FAQPage:
<script type="application/ld+json">
{"@context": "https://schema.org", "@type": "BlogPost",
"headline": "How we pick indigo dyes",
"datePublished": "9 October 2026",
"dateModified": "2026-10-09T10:30:00",
"author": {"@type": "Person", "name": "Asha Rao"}}
</script>
<script type="application/ld+json">
{"@context": "https://schema.org", "@type": "FAQPage",
"mainEntity": [{"@type": "Question", "name": "Is indigo colourfast?",
"acceptedAnswer": {"@type": "Answer", "text": "Wash separately the first few times."}}]}
</script>
broken_json.html is an Organization block with a trailing comma after the url value. fixed_article.html is the article corrected: BlogPosting, "datePublished": "2026-10-09T09:00:00+05:30", "dateModified": "2026-10-09T10:30:00+05:30".
I ran the script with Python 3.13 and python3 -I on 2026-10-09. The vocabulary file it loaded had 939 classes and 1,538 properties. Real output (exit code 1, as errors were found):
vocabulary: 939 classes, 1538 properties
good_product.html: clean
bad_product.html: 7 finding(s)
ERROR (root): 'price' is not defined for Product (its domain: DonateAction, Offer, PriceSpecification, TradeAction)
ERROR (root): 'inStock' is not a schema.org property
ERROR (root): 'rating' is not a schema.org property (did you mean starRating?)
ERROR aggregateRating.ratingValue: '4.7' not found in visible page text
ERROR aggregateRating.reviewCount: '212' not found in visible page text
ERROR offers: availability 'In stock' is not an ItemAvailability value
ERROR offers: priceCurrency 'Rs' is not a 3-letter ISO 4217 code
bad_article.html: 4 finding(s)
ERROR (root): unknown type 'BlogPost' (did you mean BlogPosting?)
ERROR (root).datePublished: '9 October 2026' is not ISO 8601
WARN (root).dateModified: no timezone (Google recommends one)
INFO (root): Google's changelog says FAQ rich results stopped showing on 2026-05-07
broken_json.html: 1 finding(s)
ERROR block 1: invalid JSON (Illegal trailing comma before end of object at line 4 col 30)
fixed_article.html came back with one finding, INFO (root): recommended property 'image' missing (none are required), because I left the image out on purpose.
Read the results with care. The "did you mean starRating?" hint for rating is wrong; the right fix is the aggregateRating block that was already there, so treat hints as clues, not answers. The two visible-text errors are the most valuable ones: the page body never mentions 4.7 or 212 reviews, so that markup would describe reviews no reader can see, which Google's guidelines list as a violation. And the unknown-type error stopped property checks on that node, so fix the type, rerun, and expect more findings.
What can't a script like this catch?
- Whether the facts are true. If the page says the kurta is in stock and it is not, both page and markup are wrong in sync. Pull price and availability from the same live source for both.
- Whether Google will use it. See the no-guarantee rule above. I did not run the Rich Results Test, URL Inspection or Schema Markup Validator; run them on a few deployed pages, as Google's own steps suggest.
- Feature-specific requirements beyond the ones coded here. The script knows Product, Offer and Article rules. If you mark up other types, read that type's page on Search Central and add rules.
- Type choice. Whether a page should be a
Product, aServiceor nothing is a judgement. Google's guidelines say the markup must be a true representation of the page. - Vocabulary drift. The vocabulary file changes over time. Re-download it when you update the validator, and pin the date in your repo.
How do you roll it out?
- Decide per page type: template in code, or LLM draft (use the table above).
- Generate for ten pages. Run the validator and read every finding.
- Fix the prompt or template for the most common finding, then rerun the same ten.
- Deploy a few pages and check them with the Rich Results Test and URL Inspection. Use the Schema Markup Validator for types that don't produce rich results.
- Add the validator to CI so a template change can't silently break markup on hundreds of pages.
- Remove FAQPage markup from your plan if the only reason was a rich result.
Related reading: the same check-then-trust pattern applies to page metadata in alt text and SEO metadata generation, and to product titles in feed optimization for Merchant Center. If you want AI search engines to cite your pages, see generative engine optimization, where the point that Google needs no special AI files is covered.



