There are two shapes a page can hand you its structured data in, and if you only handle the first one you will report that a correctly marked-up page has no structured data at all.
Shape one, the one every tutorial shows:
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "..."
}
Shape two, the one a large share of the real web actually emits:
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Organization", "name": "..." },
{ "@type": "WebSite", "url": "..." },
{ "@type": "WebPage", "@id": "..." },
{ "@type": "Article", "headline": "..." }
]
}
Shape two has no top-level @type. A check written as data['@type'] == 'Article' returns false. Not "malformed" — false. The page looks empty.
Who emits shape two
Yoast SEO and RankMath both do, by default, and they don't offer a flat-output mode. Between them that's a very large fraction of every WordPress site with an SEO plugin, which is a very large fraction of the web.
And it's the better shape. The @graph form lets nodes reference each other by @id — the Article points at the WebPage it lives on, which points at the WebSite, which points at the publishing Organization. One entity graph instead of four disconnected islands repeating the same publisher name. Both plugins are right to emit it.
Which makes a parser that can't read it entirely the parser's problem.
It also makes shape one — the shape in every tutorial — the shape almost nobody's CMS actually produces. Including in my own writing: I've handed out Organization + WebSite JSON-LD templates twice on this site, in How to Make Your Site Quotable by AI in 30 Minutes and in The 4-Layer Model for AI Search Readiness, and both are flat. They're correct — a flat block is valid, and if you're adding JSON-LD by hand to a page that has none, it's the right thing to write.
They just aren't what you'll find when you go read a page that already has markup. If you followed either of those posts on a WordPress site with Yoast installed, your plugin was already emitting a @graph and your hand-written block landed next to it, which is a different situation than the one I described. That's on me, and it's the gap this post exists to close.
How many parsers get it wrong
I tested twelve structured-data detectors — validators, audit tools, citability checkers, some open source and some hosted — against the same page, once with flat JSON-LD and once with the same types wrapped in a @graph.
Nine of the twelve reported no structured data on the @graph version.
Including mine. My own tool had this bug, which is the only reason I went looking. A page I knew had good markup was scoring zero on schema, and my first assumption was that the page was broken.
The fix is boring
Flatten before you inspect. Once, at the parse boundary, so no downstream check has to know about either shape:
def iter_nodes(data):
"""Yield every schema.org node, flat or @graph, at any nesting depth."""
if isinstance(data, list):
for item in data:
yield from iter_nodes(item)
elif isinstance(data, dict):
if "@graph" in data:
yield from iter_nodes(data["@graph"])
if "@type" in data:
yield data
Three things worth pointing out.
data can be a list at the top level. A page is allowed to ship several <script type="application/ld+json"> blocks, and some CMSes put an array in one block. Handle it or you'll drop everything after the first.
The @graph check runs before the @type check, and both run — not elif. A node can legitimately carry its own @type and nest a @graph. Using elif there is how you silently drop half a graph.
Recursion, not one level of unwrapping. I have seen @graph inside @graph. Rare, but it costs nothing to handle.
Then every check downstream stops caring:
types = {n["@type"] for n in iter_nodes(payload) if isinstance(n.get("@type"), str)}
The isinstance guard is there because @type is allowed to be an array — "@type": ["Person", "Organization"] is valid, and a bare set comprehension will throw an unhashable-type error on it the first time a real page hands you one.
Check your own page in ten seconds
Paste into the console on any page you own:
[...document.querySelectorAll('script[type="application/ld+json"]')]
.map(s => JSON.parse(s.textContent))
.forEach(d => console.log(d['@graph'] ? '@graph, ' + d['@graph'].length + ' nodes' : 'flat, ' + d['@type']))
If it prints @graph, then any tool that told you your structured data was missing was telling you about itself.
Why I care about this beyond my own bug
The reason a parser bug is worth a thousand words: the check that's wrong is usually the one you trust to tell you something is wrong.
I spent a while assuming a page was badly marked up because a tool said so. The tool was the broken thing. That failure mode is much worse than a false negative on a real problem, because it points you at the wrong file and you fix something that was never broken.
So when an audit tool tells you a page has no structured data, and you can see the JSON-LD in the source with your own eyes, believe your eyes and check the shape first.
Next in this series: two URLs that differed by one trailing character, and the re-audit loop that ate 86.9% of my database.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.