How to validate structured data

Pull every application/ld+json block out of the page and test it against the schema.org vocabulary: whether the @type exists, whether each property is defined on that type, and whether each value fits the declared range. Then compare the values against the text the page renders. Both halves run locally against a downloaded vocabulary file.

Why check this

Run this on staging sign-off and after any template change that touches product, article or breadcrumb markup. The failure it prevents is silent divergence: a pricing change lands in the template and not in the JSON-LD, so the markup keeps advertising last quarter's price while the page shows the new one.

The boundary matters. A vocabulary check decides whether the markup is well formed and whether its terms exist and are used where the vocabulary defines them. It decides nothing about whether a search engine will show a rich result. That verdict comes from one vendor's own tooling, the Rich Results Test publishes no API, and no local run stands in for it.

Prerequisites

// fixtures.js
const http = require('http'), fs = require('fs'), path = require('path');
const TYPES = { '.html': 'text/html; charset=utf-8', '.png': 'image/png' };
http.createServer((req, res) => {
  const url = req.url.split('?')[0];
  if (url === '/img/moved.png') return res.writeHead(302, { location: '/img/card.png' }).end();
  if (url === '/img/missing.png') return res.writeHead(404, { 'content-type': 'text/html' }).end('<h1>404</h1>');
  const file = path.join(__dirname, 'fixtures', path.normalize(url).replace(/^[\\/]+/, ''));
  if (!file.startsWith(path.join(__dirname, 'fixtures')) || !fs.existsSync(file)) {
    return res.writeHead(404).end('not found');
  }
  const body = fs.readFileSync(file);
  res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
                       'content-length': body.length });
  res.end(body);
}).listen(8919, '127.0.0.1', () => console.log('fixtures on http://127.0.0.1:8919, pid ' + process.pid));
<!-- fixtures/product.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Bolt cutter 600 mm</title>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Bolt cutter 600 mm",
  "datePublished": "2026-08-01",
  "aggregateRating": 4.8,
  "offers": {"@type":"Offer","price":"19.00","priceCurrency":"EUR","availability":"https://schema.org/InStock"}
}
</script>
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbLists",
  "itemListElement": [{"@type":"ListItem","position":1,"name":"Tools","item":"http://127.0.0.1:8919/"}]
}
</script>
</head><body>
<h1>Bolt cutter 600 mm</h1>
<p class="price">EUR 29.00</p>
<p class="stock">Out of stock, back on 2026-10-15</p>
</body></html>
// vocab-check.mjs
import { readFile } from 'node:fs/promises';
const [vocabFile, src] = process.argv.slice(2);
const graph = JSON.parse(await readFile(vocabFile, 'utf8'))['@graph'];
const bare = (x) => x['@id'].replace('schema:', '');
const list = (v) => (v === undefined ? [] : [].concat(v).map(bare));
const cls = new Map(), prop = new Map(), member = new Map();
for (const n of graph) {
  const id = bare(n), t = [].concat(n['@type'] ?? []).map((x) => (typeof x === 'string' ? x : x['@id'])).join(' ');
  if (t.includes('rdfs:Class')) cls.set(id, list(n['rdfs:subClassOf']));
  else if (t.includes('rdf:Property')) prop.set(id, { domain: list(n['schema:domainIncludes']), range: list(n['schema:rangeIncludes']) });
  else if (t.startsWith('schema:')) member.set(id, t.replace('schema:', ''));
}
const DATA = new Set(['Text', 'Number', 'Integer', 'Float', 'Boolean', 'Date', 'DateTime', 'Time', 'URL', 'DataType']);
const ancestors = (t, seen = new Set()) => {
  if (!cls.has(t) || seen.has(t)) return seen;
  seen.add(t);
  for (const p of cls.get(t)) ancestors(p, seen);
  return seen;
};
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
const out = [];
const walk = (node, path) => {
  if (Array.isArray(node)) return node.forEach((v, i) => walk(v, `${path}[${i}]`));
  if (!node || typeof node !== 'object') return;
  const type = node['@type'];
  if (type && !cls.has(type)) out.push(`${path}: @type "${type}" is not in the vocabulary`);
  for (const [k, v] of Object.entries(node)) {
    if (k.startsWith('@')) continue;
    const p = prop.get(k);
    if (!p) { out.push(`${path}.${k}: not a schema.org property`); continue; }
    if (type && cls.has(type) && p.domain.length && ![...ancestors(type)].some((a) => p.domain.includes(a))) {
      out.push(`${path}.${k}: not defined on ${type} (domainIncludes ${p.domain.join(', ')})`);
    }
    const enumOf = typeof v === 'string' ? member.get(v.replace(/^https?:\/\/schema\.org\//, '')) : null;
    const literalOk = p.range.some((r) => DATA.has(r)) || (enumOf && p.range.includes(enumOf));
    if (typeof v !== 'object' && p.range.length && !literalOk) {
      out.push(`${path}.${k}: literal ${JSON.stringify(v)} where rangeIncludes is ${p.range.join(', ')}`);
    }
    walk(v, `${path}.${k}`);
  }
};
let i = 0;
for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
  i += 1;
  try { walk(JSON.parse(m[1]), `block${i}`); } catch (e) { out.push(`block${i}: INVALID JSON, ${e.message}`); }
}
console.log(out.length ? out.join('\n') : 'no vocabulary findings');
console.log(`${i} block(s), ${out.length} finding(s), against ${cls.size} types and ${prop.size} properties`);
// claims-check.mjs
import { readFile } from 'node:fs/promises';
const KEYS = new Set(['name', 'price', 'ratingValue', 'reviewCount', 'availability', 'headline']);
const src = process.argv[2];
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
const visible = html
  .replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
  .replace(/<head[\s\S]*?<\/head>/i, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const claims = [];
const walk = (n, p) => {
  if (Array.isArray(n)) return n.forEach((v, i) => walk(v, `${p}[${i}]`));
  if (n && typeof n === 'object') return Object.entries(n).forEach(([k, v]) => walk(v, p ? `${p}.${k}` : k));
  const key = p.split('.').pop().replace(/\[\d+\]$/, '');
  if (KEYS.has(key)) claims.push([p, String(n)]);
};
for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
  try { walk(JSON.parse(m[1]), ''); } catch { /* syntax is the other check's job */ }
}
for (const [p, v] of claims) {
  const short = v.replace(/^https?:\/\/schema\.org\//, '');
  console.log(`${visible.toLowerCase().includes(short.toLowerCase()) ? 'on page   ' : 'NOT ON PAGE'}  ${p} = ${v}`);
}
console.log(`visible text: ${visible}`);

Steps

  1. Step 1.

    Download the vocabulary once and keep it on disk.

    curl -sS --compressed -o schemaorg.jsonld -w '%{http_code} %{size_download}\n' \
      https://schema.org/version/latest/schemaorg-current-https.jsonld
    
    200 1551177

    Every later run reads this file. The check never needs the network again.

  2. Step 2.

    List the predicates the vocabulary actually uses, before trusting any tool that reports a missing required property.

    node -e "const g=JSON.parse(require('fs').readFileSync('schemaorg.jsonld','utf8'))['@graph']; const k=new Set(); for(const n of g) for(const x of Object.keys(n)) k.add(x); console.log([...k].sort().join(' '))"
    
    @id @type owl:disjointWith owl:equivalentClass owl:equivalentProperty rdfs:comment rdfs:label rdfs:seeAlso rdfs:subClassOf rdfs:subPropertyOf schema:contributor schema:domainIncludes schema:inverseOf schema:isPartOf schema:rangeIncludes schema:sameAs schema:source schema:supersededBy

    Nothing in that list marks a property as required. domainIncludes and rangeIncludes are the whole constraint vocabulary. Required properties are a consumer's rule, so any tool reporting one is applying a policy it brought with it.

  3. Step 3.

    Start the fixture server and run the validator against the planted defects.

    node vocab-check.mjs schemaorg.jsonld http://127.0.0.1:8919/product.html
    
    block1.datePublished: not defined on Product (domainIncludes Certification, CreativeWork)
    block1.aggregateRating: literal 4.8 where rangeIncludes is AggregateRating
    block2: @type "BreadcrumbLists" is not in the vocabulary
    block2.itemListElement[0].item: literal "http://127.0.0.1:8919/" where rangeIncludes is Thing
    2 block(s), 4 finding(s), against 1010 types and 1676 properties

    Three of those four are defects. datePublished belongs to CreativeWork, not Product. aggregateRating takes an AggregateRating node, so the bare 4.8 carries no rating count and no scale. BreadcrumbLists is a typo for BreadcrumbList and no consumer resolves it. The fourth line is the validator being literal: a URL string is a legal node reference in JSON-LD, and a range check cannot tell that from a wrong literal.

  4. Step 4.

    Run the same validator against a page from your own build output, here this site's own static export.

    node vocab-check.mjs schemaorg.jsonld out/check/check-301-vs-302-redirect/index.html
    
    block2.totalTime: literal "PT4M" where rangeIncludes is Duration
    block4.itemListElement[0].item: literal "https://h2check.org/" where rangeIncludes is Thing
    block4.itemListElement[1].item: literal "https://h2check.org/check/" where rangeIncludes is Thing
    block4.itemListElement[2].item: literal "https://h2check.org/clusters/performance/" where rangeIncludes is Thing
    block4.itemListElement[3].item: literal "https://h2check.org/check/check-301-vs-302-redirect/" where rangeIncludes is Thing
    6 block(s), 5 finding(s), against 1010 types and 1676 properties

    Five findings and zero defects. PT4M is an ISO 8601 duration, the way a Duration is written as a literal, and the four item values are node references by URL. A check that reports only counts would call this page worse than the fixture. Read the lines.

  5. Step 5.

    Compare the values in the markup against the text the page renders.

    node claims-check.mjs http://127.0.0.1:8919/product.html
    
    on page     name = Bolt cutter 600 mm
    NOT ON PAGE  offers.price = 19.00
    NOT ON PAGE  offers.availability = https://schema.org/InStock
    NOT ON PAGE  itemListElement[0].name = Tools
    visible text: Bolt cutter 600 mm EUR 29.00 Out of stock, back on 2026-10-15

    The markup passed every syntax and vocabulary rule and still says the item costs 19.00 and is in stock, while the page shows 29.00 and out of stock. No validator built on the vocabulary alone produces this line. The breadcrumb entry is expected: navigation labels live outside the text this script reads.

  6. Step 6.

    Find the process holding the fixture port, so you stop that one and nothing else.

    netstat -ano | grep ':8919 '
    
      TCP    127.0.0.1:8919         0.0.0.0:0              LISTENING       37512

    The last column is the process id. Stop that id and nothing else, then run the same command again. It prints nothing when the port is free.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | @type "X" is not in the vocabulary | A typo or an invented type. No consumer resolves it | Fix the spelling against the vocabulary file | | not defined on T (domainIncludes ...) | The property exists but belongs to other types | Move it to the node that owns it, or change the node's type | | literal "v" where rangeIncludes is N | Either a missing nested node, or a URL reference, or an ISO literal | Read the value. Only a plain string where a node is meant is a defect | | INVALID JSON | The block is dropped whole before any of this runs | Fix the syntax first, then rerun | | NOT ON PAGE for a price, rating or availability | The markup and the rendered page disagree | Fix the source that feeds the markup. This is a data defect, not a markup one | | No findings at all | The terms are well formed and used where defined | Nothing here says a rich result will appear |

Common mistakes

Sign: A validator reports a missing required property and the schema.org page for that type lists no required properties at all.Cause: The vocabulary has no predicate for requirement, as step 2 shows: the only constraint terms it publishes are domainIncludes and rangeIncludes. Requirements come from whichever consumer the tool was written for, and two tools can disagree on the same markup without either being wrong.
Sign: A range check floods a correct page with findings while the actually broken page produces fewer.Cause: JSON-LD lets a node be referenced by its URL and lets typed values such as durations and dates be written as plain strings. A check that compares JavaScript types against rangeIncludes cannot separate those from a literal used where a node was meant. Step 4 produced five such lines on markup with no defects in it.
Sign: Markup validates everywhere and the price shown in a shared link or a listing is stale.Cause: The template and the structured data are fed by different code paths, and only the template was updated. Nothing in the syntax or the vocabulary notices. The comparison in step 5 is the only part of this procedure that catches it.

What to check next

FAQ

How to check schema markup of a website?

Fetch the page, extract the application/ld+json blocks, and run them against the vocabulary file as in step 3. For markup built in the browser, read the blocks from the DOM instead, as the JSON-LD syntax procedure shows.

Is there a tool for testing schema.org markup?

Several, and they disagree, because each encodes the requirements of the consumer it was built for. The vocabulary file is the one source they share. Validate against it first, then run whichever vendor tool matters for your release.

How to check if a page has schema markup?

Count the blocks: curl -sS <url> | grep -c 'application/ld+json'. That counts string matches, not parsed blocks, so treat a non-zero result as a reason to run the validator rather than as an answer.

Does this tell me whether a rich result will show?

No. Each search engine decides eligibility against its own documented requirements, and the Rich Results Test publishes no API. This procedure gates the part you control: correct, well formed markup that matches the page.

Which findings should block a release?

Unknown types, properties used outside their domain, invalid JSON, and any line where the markup and the visible page disagree. Range findings on URL references and ISO literals are noise from the check itself.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0schema.org vocabulary 2026-09-11 snapshot

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny