DEV Community

Daniel Pertu
Daniel Pertu

Posted on

1,734 structured data nodes, zero invalid properties, and half of them describe rich results that no longer exist

Our pages carry structured data, and I had never validated it against anything except my own reading of the documentation. So I wrote the check: fetch all 76 public pages of Nakodo, pull every application/ld+json block, walk every node, and compare each @type and each property against schema.org's own machine-readable vocabulary.

voc = json.load(open("schemaorg-current-https.jsonld"))["@graph"]   # 3,256 nodes
# 1,009 rdfs:Class, 1,694 rdf:Property
Enter fullscreen mode Exit fullscreen mode

The vocabulary is a download, not an API: https://schema.org/version/latest/schemaorg-current-https.jsonld, 1.5 MB. Classes carry rdfs:subClassOf, properties carry schema:domainIncludes (which types may use me) and schema:rangeIncludes (what values I accept). That is everything you need to check markup properly, including the question a visual tool will not answer: is this property legal on this type.

The result

pages                                76
ld+json blocks                      150   (74 pages have 2, two pages have 1)
nodes with an @type               1,734
distinct types                       21
distinct properties                  33
types not in the vocabulary           0
properties not in the vocabulary      0
properties outside their type's domain  0
@id references                      290
@id references that do not resolve     0
Enter fullscreen mode Exit fullscreen mode

All of it legal. I was most pleased about the last pair: 290 places where one node points at another by @id, mostly publisher and isPartOf pointing back at the one Organization node, and every single one resolves to a node defined on the same page. That is the sort of thing that rots silently when you copy a block between templates.

The per-type census is where the post turned:

Question        324     Organization    162     HowToStep   122
Answer          324     WebSite          76     FAQPage      65
ListItem        256     BreadcrumbList   73     Article      58
Thing           189     DefinedTerm      27     HowTo        21
Offer            10     CollectionPage    6     ItemList      6
BlogPosting       5     WebApplication    4     WebPage       2
SoftwareApplication 2   AboutPage         1     DefinedTermSet 1
Enter fullscreen mode Exit fullscreen mode

My checker found two problems and both were mine

Before the real finding, the two false positives, because if you build this yourself you will hit both.

The vocabulary file is not only schema.org. It ships external enumerations from other ontologies, so three nodes end in :Offer:

fibo-fnd-pas-pas:Offer
schema:Offer
unece:Offer
Enter fullscreen mode Exit fullscreen mode

I had keyed my class table on the short name after the colon. FIBO's Offer has no rdfs:subClassOf, so it overwrote schema.org's, the ancestry of Offer came back as ['Offer'] instead of ['Offer', 'Intangible', 'Thing'], and my checker confidently reported that name, description and url are not allowed on an Offer. Which would be a real bug if it were true, since those three come from Thing and are allowed on everything.

voc = [n for n in voc if n["@id"].startswith("schema:")]   # the fix
Enter fullscreen mode Exit fullscreen mode

isinstance(True, int) is True in Python. My range check saw isAccessibleForFree: true, decided it was a number in a Boolean-only range, and flagged four nodes. Booleans are integers, JSON booleans parse to Python booleans, and a type check written in the obvious order will treat every true as a 1.

Both false positives looked exactly like findings: a specific property, a specific type, a repeatable count. The only reason I did not write them up as bugs is that I did not believe name could be illegal on an Offer and went to check. A validator you wrote yourself has a failure mode that a validator you downloaded does not, which is that its mistakes come out in the same shape as its results.

The real finding: 856 of the 1,734 nodes are for retired features

Add up the two biggest families:

FAQPage    65  +  Question 324  +  Answer    324   =  713
HowTo      21  +  HowToStep 122                    =  143
                                                     ----
                                                      856   of 1,734   (49%)
Enter fullscreen mode Exit fullscreen mode

Google's FAQ documentation carries two changelog entries: May 2026, "This feature will no longer appear in Google Search starting May 7, 2026", and June 2026 confirming it is no longer shown. HowTo went earlier, during 2023, and the documentation page is the best evidence of that, because the old URL for it now answers a redirect:

curl -sI "https://developers.google.com/search/docs/appearance/structured-data/how-to"
# 301 -> https://developers.google.com/search/updates#how-to-deprecation
Enter fullscreen mode Exit fullscreen mode

A documentation page that redirects to its own deprecation note is the clearest signal a platform has ever sent me.

So: half of the structured data on this site cannot produce a rich result in the search engine it was written for. It was already written. It validates perfectly. It is also, as a search investment, worth nothing, and the markup I actually get value from is the quiet half: BreadcrumbList on 73 pages, Article on 58, Organization and WebSite everywhere.

You can see the whole shape on any guide page: view source, search for ld+json, and you will find an Article with a BreadcrumbList, a HowTo whose steps are the numbered list on the page, and a FAQPage whose questions are the details elements at the bottom. Two of those four do nothing in Google today.

What I am doing about it, which is less than you would think

Not deleting it. Google's position on leftover markup is that it is harmless, and the content is on the page either way, which is the rule I wrote these blocks under in the first place: no node may claim more than the page shows. Every Question in those 324 is a question a reader can open and read, and every HowToStep is a step printed in the page. There is nothing to withdraw, because nothing was ever a claim about content that is not there.

Changing what I count it as. When I built the content cluster, "has FAQ markup" was on my checklist as an SEO task. It is not one, and has not been for a while. It is now on the list as content markup for whoever reads it, which today means the answer engines and our own llms-full.txt, and that is a claim with no measurement behind it, which I would rather say out loud than pretend otherwise.

Fixing one small inconsistency the census turned up. Our paid plans are marked up in GBP:

{"@type": "Offer", "name": "Pro", "price": 39, "priceCurrency": "GBP", "url": "https://nakodo.app/pricing"}
Enter fullscreen mode Exit fullscreen mode

and the four free calculators are marked up in dollars:

{"@type": "Offer", "price": 0, "priceCurrency": "USD"}
Enter fullscreen mode Exit fullscreen mode

Nobody is misled by the currency of zero. But our base currency is GBP and four of our ten offer nodes disagree with the site-wide convention for no reason other than that I wrote the tool pages on a different day. It is one word in the calculator page markup.

If you run this on your own site

Three checks, in increasing order of what they tell you:

  1. Does every @type exist. Catches typos and invented types, and a visual tester will tell you this too.
  2. Is every property legal on the type carrying it. This needs domainIncludes plus the subClassOf chain, and it is the check that found nothing here, which is the outcome I wanted and would not have believed without running it.
  3. Does every @id reference resolve inside the same page. Nothing in the tooling warns you about a dangling reference, and a graph with a broken internal pointer is a graph where publisher quietly means nothing.

Then count your nodes by type and ask, for each family, which search feature you were writing it for and whether that feature still exists. That is the question that turned out to be worth more than the validation, and no validator will ever ask it for you.

Sources for the retirement dates: Google's FAQ structured data docs and changelog, Google Search updates: HowTo deprecation.

Top comments (0)