Schema.org Audit:
when the validator is not enough

Measuring the gap between formally valid markup and ontologically correct markup.

Why "how to write correct JSON-LD" is the wrong question

People searching for "how to write JSON-LD" usually end up on a property reference, a copy-pasted example, and a validator that returns a positive result. The problem is that the green light certifies grammar, not meaning. And meaning is exactly what Google and AI engines read.

The key difference is therefore what Syntax (the JSON is valid), Semantics (the router connects the pages) and Ontology (the Knowledge Graph understands what we sell) each tell us.

The relationships declared in Schema.org are assertions about how the site is built. When they are wrong — when a hasPart declares a containment that does not exist, when a breadcrumb lives inside an Article instead of the WebPage, when an @id points to a node that was never defined — no automatic syntax-validation tool flags it. Yet the graph that machines build contains those errors.

SYNTAX "Is the code well written?"
→ Automated validators
SEMANTICS "Does it make sense in context?"
→ Human audit
ONTOLOGY "Does it correctly describe reality?"
→ Effects on the Knowledge Graph

Where this method comes from

The semantic-audit methodology did not come from a theoretical hypothesis. It formed by accumulation: one error at a time, a template that generated the same problem across hundreds of pages, a fix that revealed the next pattern. There was no initial phase in which someone "designed" this architecture: there was a path of discovery, and this document is the formalisation of what worked.

From that experience came a precise conviction: validators check grammar, not meaning. A hasPart pointing to an external page passes validation; a breadcrumb inside an Article passes validation; an @id pointing to a non-existent node passes validation. The green light says "syntax OK", not "coherent ontology".

When the number of pages exceeds the capacity to check them one by one, errors do not disappear — they change shape, becoming systemic patterns instead of isolated typos. Architecture, not the single page, is what must be designed with care.

What a Schema.org audit analyses

The audit does not stop at checking whether the code is valid. It analyses the graph as a whole and verifies that it holds up logically. Here are the areas it checks.

1. Type coherence

Is every @type declared only once in the graph, without contradictions? Is the same @id never classified in one place as WebPage and in another as WebApplication? This is the Ontological Chimera: the validator does not flag it, but the AI reading the graph stalls in front of two incompatible types on the same node.

2. @id resolvability

Does every referenced @id actually exist somewhere — in the same @graph or on a reachable page? This analysis hunts down Dangling Nodes: edges pointing to nothing. On one page it is a detail; on 250 pages it is a pattern that weakens the entire graph.

3. Property appropriateness

Is containment declared with hasPart only when it is real? Do external thematic links use significantLink? Is the breadcrumb in the WebPage or has it crept inside the Article? These are all choices that pass the validator but change the meaning of the graph.

4. Data parity

Does every value in the JSON-LD (rating, price, claim) also appear in the DOM visible to the user? Hidden markup is a violation of Google's guidelines and can lead to a manual action for misleading markup.

5. Container / content separation

Do navigation loops (menu, related, hub) point only to containers (#webpage) or also to final content (#article, #app)? A loop that forces the WebPage type onto a software ID is an error invisible to the validator but lethal to the graph.

6. Fragment naming convention

Does every page follow a rigid convention for fragments (#webpage, #article, #breadcrumb, #organization)? An effective audit checks that @id values are consistent across the site's pages, without surprises generated by different templates.

7. Diagnostics of resolution conflicts

When an error seems to make no sense — because two structurally identical pages produce different outcomes — the cause lies in reference resolution, not in the page. The same @id appearing in multiple properties with different ranges, an array with mixed elements (typed and untyped), an unrecognised custom fragment: these are all mechanisms that generate downstream errors, apparently unrelated to the real cause. The Diagnostics chapter covers the method to isolate them.

The operating rules: how to write JSON-LD that holds the graph together

Nineteen patterns that recur in production, with examples of correct and incorrect code. Every rule exists because a real case generated it: a template that got it wrong on 250 pages, an audit that revealed a Dangling Node, a validator that gave a green light to an ontologically broken relationship.

hasPart indicates literal composition: X physically contains Y. significantLink indicates editorial relevance: Y is one of the most important links on the page, but it is not contained in X.

Operating test: translate the relationship into a sentence ("X physically contains Y"). If it sounds forced, the correct property is significantLink.

significantLink accepts strings only. Not Reference Nodes, not objects. If you pass {"@id": "..."} the Schema.org validator returns a blocking error.

// ❌ Validator error
"significantLink": [{ "@id": "https://site.com/page/#article" }]

// ✅ Correct
"significantLink": ["https://site.com/page/#article"]

Critical: always include the final fragment (#article, #app, #webpage) in the string. The syntax stays valid and the AI knows exactly which node you are pointing to, avoiding Dangling Nodes.

3. The breadcrumb: where it really belongs

The breadcrumb belongs to the WebPage, not to the Article. The reason is ontological: the breadcrumb describes a URL path, and URLs belong to pages, not to content. Many sites put it inside the Article and Google still shows the path in the SERP — hence the false perception that it is fine. At scale it becomes a systemic pattern.

// ✅ Correct pattern
{
  "@type": "WebPage",
  "@id": "/page/#webpage",
  "breadcrumb": { "@id": "/page/#breadcrumb" },
  "mainEntity": { "@id": "/page/#article" }
}

Architectural rule: assign the breadcrumb at the page-template level, not the entity level. In WordPress → single.php; in React → page layout, not content component.

4. Reference Node vs Stub Node

When you reference a node defined elsewhere, use only @id. Adding @type without the essential properties (name, publisher, etc.) creates a partial entity, which at scale generates ambiguity about which is the authoritative version of the entity.

// ❌ STUB — creates a partial entity
"hasPart": [
  { "@type": "WebPage", "@id": "/p1/#webpage", "url": "/p1/" }
]

// ✅ REFERENCE NODE — flattened graph
"hasPart": [
  { "@id": "https://site.com/p1/#webpage" }
]

5. ItemList: when to generate it and when not to

ItemList is a type, not a property. It should only be generated when the page is structurally a list: hubs, category indexes, rankings.

Every ListItem must have a readable name. A bare URL is a weak signal.

6. Mixed data formats: handle both

A property can be populated in two valid forms. The generator must recognise both.

// Format A
["/page-a/", "/page-b/"]

// Format B
[{ "id": "/page-a/", "type": "..." }]

If the code expects only one format, it works until someone — maybe months later, maybe a different collaborator — writes the list in the other. The markup silently stops being valid.

7. ID conflicts: the sameAs bridge

If you inject a global site-wide Organization and a richer Organization on the "About Us" page with a different @id, Google may treat them as two distinct entities. Do not modify the global template: link the two entities with sameAs.

{
  "@type": "Organization",
  "@id": "https://site.com/#org-detail",
  "sameAs": "https://site.com/#org"
}

8. The Ontological Chimera

An ID declared as WebApplication in one part of the graph and as WebPage in another is a logical contradiction. The validator stays silent, the AI stalls in front of two incompatible types on the same node. The most frequent cause: a navigation loop that forces @type: "WebPage" onto every element, even when the ID belongs elsewhere to software.

Rule: navigation loops (menu, related, hub) must point only to containers (#webpage), never to final content (#article, #app).

9. Target typing and rich snippets

@type: "WebApplication" is semantically correct but Google requires the SoftwareApplication class plus operatingSystem for the rich result. A single type is not enough. Use multiple inheritance.

"@type": ["SoftwareApplication", "WebApplication"],
"operatingSystem": "Web Browser"

Same pattern when a range constraint blocks a semantically valid link:

// teaches expects Text or DefinedTerm
"@type": ["WebApplication", "DefinedTerm"],
"@id": "/software/#app",
"name": "Advanced SEO Protocol"

10. Container ≠ content

A page that hosts a tool is not the tool. Separate the entities with mainEntity.

// ❌
"@type": ["WebPage", "WebApplication"]

// ✅
{
  "@type": "WebPage",
  "@id": "/tool/#webpage",
  "mainEntity": {
    "@type": "WebApplication",
    "@id": "/tool/#app",
    "name": "SEO Calculator"
  }
}

Anti-pattern: applying WebPage or Article indiscriminately in a shared header. Fix it with semantic routing — the type depends on the data, not just the layout.

11. about vs mentions

Too many about values dilute the signal: the page competes for the wrong queries.

12. Data parity

Every value in the JSON-LD must be visible in the rendered DOM. If you declare aggregateRating: 4.8 and the DOM has no badge showing "4.8", you are violating Google's guidelines. Risk: manual action for misleading markup.

13. Fragment naming convention

Adopt a rigid convention and check it periodically. Example:

Validators do not open URLs: they do not verify that the @id actually exists on the other side. A Fragment Audit — extracting all @id values used in navigation loops and verifying that they point to containers — is the only way to catch them.

14. hasPart + ItemList: the index trap

Do not put an ItemList inside hasPart. ItemList is classified as Intangible (an abstract data structure), not as CreativeWork. A book has chapters, not "a list of data".

// ❌
"hasPart": { "@type": "ItemList", "itemListElement": [...] }

// ✅
"hasPart": [
  { "@id": "https://site.com/chapter-1/#webpage" },
  { "@id": "https://site.com/chapter-2/#webpage" }
]

15. The bidirectional CollectionPage pattern

On a true Topic Cluster — a Pillar Page that organises a series of deep dives — bidirectionality is valuable:

16. Global identifiers: citing third parties

If you cite third-party software (isBasedOn: SoftwareApplication), Google activates the commercial rich-snippet filters and floods you with warnings for missing offers and operatingSystem. The lazy solution is to downgrade to Thing — which lobotomises the graph. The correct solution is to point to a global IRI.

"isBasedOn": {
  "@type": "SoftwareApplication",
  "@id": "https://www.wikidata.org/wiki/Q161676",
  "name": "WordPress"
}

The bot recognises an external citation, not a local distribution: the commercial filters are disarmed, and the @type is preserved.

17. Cross-property @id conflict

When the same @id appears in multiple properties with different ranges within the same graph, and is not defined locally with an explicit @type, the validator resolves it only once — at the first occurrence — and propagates the resulting type to all the others. If the resolved type is weak (Thing), the downgrade can invalidate properties that require stricter ranges such as CreativeWork.

// ❌ Conflict: same @id in hasPart (requires CreativeWork)
// and in isRelatedTo (free range), with no local @type

{
  "@graph": [
    {
      "@type": "CollectionPage",
      "hasPart": [
        { "@id": "https://site.com/page/#article" }
      ]
    },
    {
      "@type": "Service",
      "isRelatedTo": [
        { "@id": "https://site.com/page/#article" }
      ]
    }
  ]
}

// ✅ Solution: differentiate the fragments
// hasPart points to the content (#article)
// isRelatedTo points to the container (#webpage)

{
  "@graph": [
    {
      "@type": "CollectionPage",
      "hasPart": [
        { "@id": "https://site.com/page/#article" }
      ]
    },
    {
      "@type": "Service",
      "isRelatedTo": [
        { "@id": "https://site.com/page/#webpage" }
      ]
    }
  ]
}

The diagnostic signal is not the error message, which appears downstream at a seemingly unrelated point. It is the presence of the same @id in multiple properties. When you find that pattern, look there for the cause before looking elsewhere.

18. Fragment auto-inference: the fragile dependency

Pure Reference Nodes ({"@id": "..."}) work only if the validator can resolve the @id. There are three levels of resolution, in priority order:

  1. Local definition: the @id exists in the @graph of the current page as an object with an explicit @type. Resolution guaranteed.
  2. Fragment auto-inference: if the fragment is in a whitelist of known conventions (#webpage → WebPage, #article → Article, #faq → FAQPage, #breadcrumb → BreadcrumbList), the validator infers the type.
  3. Downgrade to Thing: if the fragment is custom and there is no local definition, the type is Thing. This can invalidate properties with stricter ranges.

Practical consequence: pure Reference Nodes work "by luck" when they use standard fragments. One custom fragment anywhere in the graph and the mechanism breaks silently. An effective audit verifies that every referenced @id is either whitelisted or explicitly defined in the local graph.

19. Index as a view, not a container

A page that lists other pages does not contain them. They are two ontologically different relationships:

The temptation to use hasPart on an index is strong ("the index contains the chapters"). It is ontologically wrong: the chapters are part of the book, not of the index that lists them.

// ❌ Index with hasPart towards the chapters
{
  "@type": "CollectionPage",
  "@id": "/index/#webpage",
  "hasPart": [
    { "@id": "/chapter-1/#webpage" },
    { "@id": "/chapter-2/#webpage" }
  ]
}

// ✅ Index with significantLink (strings) towards the chapters
{
  "@type": "CollectionPage",
  "@id": "/index/#webpage",
  "significantLink": [
    "https://site.com/chapter-1/#webpage",
    "https://site.com/chapter-2/#webpage"
  ]
}

Containment stays on the real container (the book, the work, the collection). The index is a view that exposes the list, not a physical container.

Consequence for narrative patterns: in branching content (interactive path, step-by-step tutorial, decision tree), each node belongs to the previous node with isPartOf, and the subsequent nodes are declared with significantLink. The index remains the overview view with significantLink towards all nodes. No hasPart.

Diagnostics: when two identical structures give different outcomes

There is a moment in every audit when two apparently identical pages get opposite results from the validator. One passes, the other does not. The code looks the same. The instinctive reaction is to think of a validator bug, a cache, a temporary problem.

It is none of those. The reason is that validators resolve references contextually, not in isolation: it depends on what is in the rest of the graph, not just on what you write at the specific point.

The principle: resolve once, propagate

When a validator encounters a reference to an @id, it resolves it only once — at the first occurrence — and uses the result for all subsequent occurrences. The decision is not re-evaluated for each property.

If the first occurrence is in a context that does not allow the type to be determined (because the @id is not defined locally and the fragment is not recognisable), the validator classifies it as Thing. That Thing is then propagated to the other properties that reference the same @id.

If one of those properties requires a more specific range (hasPart requires CreativeWork, for example), the Thing is not compatible and the validator reports the error on the wrong property — apparently unrelated to the real cause.

The suspicious pattern: same @id in multiple properties

The most important diagnostic signal is not the error message, it is the presence of the same @id in more than one property within the same graph.

When you find this pattern, check two things:

  • Is the @id defined locally with an explicit @type? If yes, no problem: resolution is guaranteed and there is no downgrade.
  • Do the properties using it have compatible ranges? If one is hasPart (restricted range) and the other is a generic property, the stricter range requires a stronger type than Thing.

If the @id is not defined locally and the properties have incompatible ranges, you have found the cause. Differentiate the fragments: one property points to the content (#article), the other to the container (#webpage). They are two different entities by nature, and the conflict is resolved without touching the article's type.

Internal inconsistency within arrays

A related and subtle case: an array (hasPart, mentions, isRelatedTo) in which some elements have an explicit @type and others do not. The validator looks for internal coherence, and in its absence:

  • It may downgrade the typed elements to the weakest type among the others
  • It may report errors on the untyped elements even though the problem is uniformity
  • The error message may not name the element that is actually problematic

Operating rule: in an array, elements must be all typed or all without @type. Never mixed. A mixed array is an array that hides problems.

URL parent vs semantic parent

Two different concepts that are often confused, and that validators do not distinguish.

The URL parent is physical: it is obtained by cutting the last segment of the URL. Every route has one, whether or not it exists as a public page.

The semantic parent is the one declared in isPartOf. It does not always coincide with the URL parent, and it must not.

Typical examples of intentional divergence:

  • A service page may be a structural child of a mini-hub and thematically related (via about) to an encyclopaedic page
  • A volume may belong to its series, not to the index page of the volumes
  • A tool may be a child of a tools centre rather than of its URL category
  • A top-level page may refer to the home page, even though its "natural" URL parent would be a prefix that is not a public page

The diagnostic test: compare the physical parent URL and the declared isPartOf. Divergences are not automatically errors. They are choices. But they must be checked one by one and classified into two categories: deliberate choices and likely errors.

Four-step verification method

Step 1 — Capture the local graph

Extract the JSON-LD of the problematic page. Identify all entities with an explicit @type and all @id references without a local definition.

Step 2 — Map the conflicts

Build a map @id → [properties using it]. Identify the @id values that appear in more than one property.

Step 3 — Resolve the conflicts

For every conflicting @id: is it defined locally with an @type? If not, is the fragment in the whitelist of known conventions? If neither, differentiate the fragments across properties, or add an explicit definition.

Step 4 — Verify auto-inference

For every @id referenced but not defined locally, verify that the fragment is in the whitelist. If it is not, add an explicit definition or change the fragment.

A practical case (generic)

Two pages with a virtually identical structure:

  • Both children of the same parent page
  • Both referenced in the parent's hasPart
  • Both with their own entity of type TechArticle
  • Both without @type in the reference

One passes the validator, the other does not. No apparent difference in the code of the two pages.

Diagnosis: the failing page is also referenced by another property — for example the isRelatedTo of a Service — with the same @id. The validator resolved that @id at the first occurrence (in isRelatedTo) and classified it as Thing because it was not defined locally, then propagated the Thing to all other occurrences — including the one in hasPart, which requires a CreativeWork range and rejects Thing.

Solution: differentiate the two references. hasPart towards the article (#article), isRelatedTo towards the page (#webpage). The two entities are different by nature — content vs container — and the conflict is resolved without touching the article's type.

Lesson: when a reference is ambiguous, the validator does not flag the ambiguity: it flags the error downstream, at a point that may seem unrelated. The useful signal is the presence of the same @id in multiple properties. That is the pattern to look for first when an error "makes no sense".

Symptom → rule map

For those arriving with an already-identified problem, this table serves as a quick index.

SymptomRule
Green validator, missing rich snippet§9 Target typing
"Element without a name" on a nested entity§4 Reference Node
Breadcrumb in the SERP but incoherent graph§3 WebPage
Dangling node, orphan @id§1, §13
Two organizations in the Knowledge Graph§7 sameAs
FAQs treated as a link list§5 scope
Wrong type forced by a loop§8 Chimera
Huge JSON-LD slowing the page down§4 flattened graph
ItemList on a single product§5 scope
Validator error on significantLink§2 string only
Two identical pages with different validator outcomes§17, Diagnostics
Error on an apparently unrelated propertyDiagnostics, Step 2
Thing error on a property with a restricted range§17
Index with hasPart towards its entries§19
Reference Node that works on one page but not on another§18

When in doubt

Translate the JSON-LD triple into a simple sentence (Subject → Verb → Object). If the sentence sounds forced, the property is wrong — regardless of what the validator says.

"This Article physically contains a Breadcrumb" → forced → the breadcrumb belongs to the WebPage.

"This WebPage contains this other external WebPage" → forced → use significantLink.

"This WebPage is a WebApplication" → wrong → use mainEntity.

"This index physically contains its chapters" → forced → the index lists, the book contains.

Final rule: every rule above exists because a validator said "OK" while the Knowledge Graph said "no". Trust the graph, not the green tick.

The operating checklist

Concrete questions that a Semantic Audit asks of every page. It is not a test to tick off once: it is a grid to reapply when a content type is added or a rule is automated.

On the single document

On the entire site

What an audit cannot do

To be useful, a method must also declare its limits.

Who it is for (and who it is not)

It is genuinely useful for editorial platforms with dozens or hundreds of related pages, e-commerce sites with a structured catalogue, topic-cluster architectures, sites with automated templates that generate markup at scale, and projects that must be read by AI.

It is overkill for brochure sites with 5–10 static pages, projects managed by standard plugins (Yoast, RankMath) that already cover Organization, WebSite, WebPage, and single-topic blogs with only one content type. On those projects, a full audit is a disproportionate investment: the plugin is enough, and the ontological part becomes a theoretical exercise with no practical impact.

Further reading: theory & practice

This page presents the audit method. The two linked guides explore it from two complementary angles on the IMWEB mother site.

📘 The theory — Advanced Structured Data The reference guide on the vocabulary: isPartOf, hasPart, significantLink, ItemList, about, mentions. Explains the ontological meaning of each property, the rules of polymorphism and target typing, data parity, and the case study on false positives. Go to the theoretical guide → 📗 The practice — Semantic Architecture at Scale The practical follow-up: what happens when theoretically correct patterns are replicated by a template across hundreds of pages. Six real "cracks" encountered in production, six rendering-engine traps, a 4-step debugging framework (with the Fragment Audit), and a data-separation architecture. Go to the practical guide →

The two guides are complementary and cross-reference each other. Theory without practice remains an intellectual exercise; practice without theory becomes a checklist of rules with no "why".

FAQ

Is following the Schema.org specifications enough to write correct JSON-LD?

Following the specifications is necessary but not sufficient. The specifications tell you which types exist, which properties they accept, and which ranges are valid. They do not tell you whether the relationship you are declaring makes sense for your site. A hasPart pointing to an external page is syntactically valid but ontologically questionable. The validator passes; the graph does not.

Does a Schema.org audit replace Google's Rich Results Test?

No, it complements it. The Rich Results Test checks compatibility with Google rich snippets: it tells you whether the markup is valid according to Search Console standards and whether a rich result will be shown. The semantic audit checks a different level: that the declared relationships are coherent, that the nodes are resolvable, that the graph holds. You can have a perfect rich result and an incoherent graph — and vice versa.

What are Dangling Nodes and why should an audit hunt them down?

A Dangling Node is an @id reference pointing to an entity that does not exist in the graph. In graph theory, an edge pointing to nothing reduces the coherence of the dataset. In practice, when an AI system builds the Knowledge Graph of your site, it finds a reference to an entity it cannot resolve and has to guess or ignore it. Repeated across hundreds of pages, this weakens the clarity of the graph.

Why do two pages with identical structure get different validator outcomes?

Because the validator resolves @id references contextually, not in isolation. If the same @id appears in multiple properties with different ranges and is not defined locally, the validator resolves it only once — at the first occurrence — and propagates the resulting type. If the type is weak (Thing), the downgrade can invalidate properties that require stricter ranges such as CreativeWork. The symptom appears downstream, in a point that seems unrelated.

How often should a Semantic Audit be redone?

Every time one of these three things changes: the Schema.org specifications, Google's guidelines on rich results, or the structure of the site. In practice: a full audit every time a new content type is added, a template that generates markup is modified, or a new section is added to the site. It is not a lifetime certificate — it is a snapshot of the state of the graph, and it must be renewed.

Do I need an audit if I already use an SEO plugin that generates JSON-LD?

It depends. A standard plugin generates correct markup for common cases: Organization, WebSite, WebPage, Article, breadcrumb. It covers the syntax for most pages of a normal site. But it does not know the specific structure of the site: it does not know which pages are hubs, which are single pieces of content, or how they connect to each other. On a small brochure site, the plugin is enough. On a topic cluster project or a catalogue, you need an audit on top of the plugin.

🔬 BEYOND THE VALIDATOR

Want to verify your JSON-LD
beyond syntax?

If you manage a site with more than a few dozen pages, the question is not "is my markup valid". The question is: "will my graph hold up when the site grows?"

Start the 360° Audit → Read the theory

We use technical cookies necessary for the site to function. No personal data is collected or shared with third parties for advertising purposes. Read the privacy notice.