When I audited the structured data of a childcare group with eight branches, every branch page carried its own top-level Organization block, and so did the homepage. The blocks had been written at different times by different people. One used the registered company name, three used the trading name, the phone number appeared in four formats, and two branches had inherited the head office address from a copied template. Nothing linked any block to any other, so a search engine parsing that site saw nine unrelated organisations with conflicting name, address and phone (NAP) data, and the founder, whose name is the reason parents trust the group, existed only as prose on the about page. What follows is the exact sequence I used to replace nine identities with one graph, with example.com standing in for the client’s domain.
Step 1. Inventory what every page currently declares
Start from what a crawler receives, not from what the CMS template claims to output. Build a list of URLs from the sitemap, then run a script that pulls every application/ld+json block, walks every node and prints its type, @id, name and telephone.
curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | sed 's/<loc>//' > urls.txt
wc -l urls.txt
Save the following as idledger.py. It uses only the Python standard library, and you will run it again in Step 7 because the same script produces the pass or fail verdict on the finished graph.
#!/usr/bin/env python3
"""idledger.py - inventory every JSON-LD node on a site and check that each
@id is declared exactly once and that every reference resolves.
Usage: python3 idledger.py urls.txt
"""
import json
import sys
import urllib.request
from collections import defaultdict
from html.parser import HTMLParser
class LdBlocks(HTMLParser):
def __init__(self):
super().__init__()
self.blocks = []
self.inside = False
def handle_starttag(self, tag, attrs):
a = dict(attrs)
self.inside = tag == "script" and (a.get("type") or "").strip().lower() == "application/ld+json"
if self.inside:
self.blocks.append("")
def handle_data(self, data):
if self.inside:
self.blocks[-1] += data
def handle_endtag(self, tag):
if tag == "script":
self.inside = False
declared = defaultdict(list) # @id -> pages that declare it with properties
referenced = defaultdict(set) # @id -> pages that only point at it
inventory = [] # (page, type, id, name, telephone)
def walk(node, page):
if isinstance(node, list):
for item in node:
walk(item, page)
return
if not isinstance(node, dict):
return
nid = node.get("@id")
props = [k for k in node if k not in ("@id", "@type", "@context", "@graph")]
if props:
inventory.append((page, node.get("@type"), nid or "(no @id)",
node.get("name"), node.get("telephone")))
if nid:
declared[nid].append(page)
elif nid:
referenced[nid].add(page)
for key, value in node.items():
if key != "@id":
walk(value, page)
urls = [u.strip() for u in open(sys.argv[1]) if u.strip()]
for url in urls:
html = urllib.request.urlopen(url, timeout=20).read().decode("utf-8", "replace")
parser = LdBlocks()
parser.feed(html)
for block in parser.blocks:
try:
walk(json.loads(block), url)
except json.JSONDecodeError as err:
print(f"BAD JSON {url} {err}")
print("INVENTORY (page, @type, @id, name, telephone)")
for row in inventory:
print(" " + " | ".join(str(x) for x in row))
problems = 0
print("\nLEDGER")
for nid in sorted(set(declared) | set(referenced)):
pages = sorted(set(declared.get(nid, [])))
refs = len(referenced.get(nid, set()) - set(pages))
if len(declared.get(nid, [])) == 1:
print(f" ok {nid} declared on {pages[0]} referenced from {refs} other page(s)")
else:
problems += 1
print(f" FIX {nid} declared {len(declared.get(nid, []))} times on {pages} referenced from {refs} other page(s)")
print("\nRESULT:", "PASS" if problems == 0 else f"{problems} @id(s) need attention")
python3 idledger.py urls.txt
On the childcare site the inventory section showed nine rows of Organization | (no @id) with four different telephone strings and three different names, followed by an empty ledger, because nothing had an @id to keep a ledger of. Each of those nine nodes is a separate entity as far as a parser is concerned, since nothing in the markup says they are the same thing, and a search engine trying to reconcile them has conflicting NAP data and no way to know which version is authoritative. The founder does not appear at all.
Step 2. Mint one @id per entity and decide where each is declared
An @id is a URL that names a node. In JSON-LD, two nodes with the same @id are the same node, so a parser merges everything said about that @id into one entity. The working rule I apply on every multi-location site is that each entity is declared once, meaning the node carries its full set of properties on exactly one page, and every other mention on the site is a reference, meaning an object containing nothing except {"@id": "..."}. A reference does not repeat the name or the phone number, it only points.
Two things go wrong when this rule is ignored. If each page declares a fresh Organization without an @id, there is nothing to merge on and you get the nine-business problem from Step 1. If each page declares the Organization with the same @id but a different phone string, the parser merges them and the merged entity now has four telephone values, which is worse than having one wrong one, because you have handed the search engine a contradiction with your own signature on it.
Use absolute URLs for every @id. A relative fragment like #org resolves against the page it appears on, so #org on the homepage and #org on a branch page are two different identifiers. Put the fragment on the URL of the page that declares the node, so the @id also tells a human where to look. The list for the childcare site, with example.com in place of the real domain, came to eleven rows in four shapes.
| Entity | Schema type | @id | Declared on |
|---|---|---|---|
| The group | EducationalOrganization | https://example.com/#org |
Homepage |
| The logo | ImageObject | https://example.com/#logo |
Homepage |
| Each of eight branches | ChildCare | https://example.com/locations/<branch-slug>#branch |
That branch’s page |
| The founder | Person | https://example.com/about#founder |
About page |
EducationalOrganization is a subtype of Organization on schema.org, which makes it a valid target for parentOrganization and a valid holder of subOrganization, founder, logo and sameAs. ChildCare is a subtype of LocalBusiness, which is both an Organization and a Place, so a branch can carry an address, geo coordinates and opening hours while still pointing upward with parentOrganization.
Step 3. Write the homepage graph
The homepage declares the group and the logo, references the founder, and lists the eight branches under subOrganization by @id only. Social profiles belong to the group, so sameAs lives here and nowhere else. The founder’s name does not appear in this block. It appears once, on the about page, and the homepage points at it.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "EducationalOrganization",
"@id": "https://example.com/#org",
"name": "Example Early Learning",
"legalName": "Example Early Learning Private Limited",
"url": "https://example.com/",
"telephone": "+91 11 4000 0000",
"email": "hello@example.com",
"logo": { "@id": "https://example.com/#logo" },
"image": { "@id": "https://example.com/#logo" },
"founder": { "@id": "https://example.com/about#founder" },
"sameAs": [
"https://www.facebook.com/exampleearlylearning",
"https://www.instagram.com/exampleearlylearning",
"https://www.linkedin.com/company/exampleearlylearning"
],
"subOrganization": [
{ "@id": "https://example.com/locations/rosewood#branch" },
{ "@id": "https://example.com/locations/lakeside#branch" },
{ "@id": "https://example.com/locations/hillview#branch" },
{ "@id": "https://example.com/locations/parkside#branch" },
{ "@id": "https://example.com/locations/riverbend#branch" },
{ "@id": "https://example.com/locations/greenfield#branch" },
{ "@id": "https://example.com/locations/sunnyside#branch" },
{ "@id": "https://example.com/locations/orchard#branch" }
]
},
{
"@type": "ImageObject",
"@id": "https://example.com/#logo",
"url": "https://example.com/assets/logo-512.png",
"contentUrl": "https://example.com/assets/logo-512.png",
"width": 512,
"height": 512,
"caption": "Example Early Learning logo"
}
]
}
</script>
Paste it into the Schema Markup Validator and you should see two nodes, an EducationalOrganization and an ImageObject, with the founder and each subOrganization shown as a bare node carrying only an @id. That is the correct appearance for a reference, because the validator reads one page and cannot see the about page where the Person lives.
Step 4. Write one branch page and generate the other seven from the same data
Each branch page declares exactly one entity, the branch, and references the group and the logo. The branch carries what Google’s LocalBusiness documentation asks for, name and address as required fields, plus telephone, geo and opening hours. The branch name includes the group name so that a result for the branch reads as a branch, and parentOrganization makes that relationship explicit in the graph rather than leaving it to string matching.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "ChildCare",
"@id": "https://example.com/locations/rosewood#branch",
"name": "Example Early Learning Rosewood",
"url": "https://example.com/locations/rosewood",
"parentOrganization": { "@id": "https://example.com/#org" },
"image": { "@id": "https://example.com/#logo" },
"telephone": "+91 11 4000 0001",
"email": "rosewood@example.com",
"priceRange": "₹₹",
"address": {
"@type": "PostalAddress",
"streetAddress": "12 Example Road, Rosewood Enclave",
"addressLocality": "Example City",
"addressRegion": "Example State",
"postalCode": "100001",
"addressCountry": "IN"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 22.5000,
"longitude": 78.5000
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
"opens": "07:30",
"closes": "18:30"
},
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": "Saturday",
"opens": "08:00",
"closes": "13:00"
}
]
}
]
}
</script>
The address, geo and openingHoursSpecification objects have no @id because they belong to the branch and nothing else will point at them. Give an @id only to nodes that are referenced from somewhere else.
Do not hand-write eight of these. The original site had four phone formats because eight people typed eight phone numbers. Keep the branch data in one place, whether a spreadsheet, a custom post type or a JSON file in the repository, with one column per field, one agreed phone format (country code, space, area code, space, number, applied to every branch and to the group) and one agreed address style, then render the block from that source. On the childcare site the branch template pulls each field from a single locations table, and the same table feeds the visible address on the page, so the markup and the text on screen cannot disagree.
Step 5. Write the about page and declare the founder
The about page is the one place the founder is declared. The Person carries a name, a role and a pointer back to the group with worksFor, and the page itself is typed as AboutPage with the Person as mainEntity. This is the node the homepage’s founder property has been pointing at since Step 3, so once this page is live the graph closes.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://example.com/about#founder",
"name": "Founder Name",
"jobTitle": "Founder and Director",
"url": "https://example.com/about",
"image": "https://example.com/assets/founder-800.jpg",
"worksFor": { "@id": "https://example.com/#org" },
"description": "Founded Example Early Learning in 2011 and oversees curriculum across all eight branches."
},
{
"@type": "AboutPage",
"@id": "https://example.com/about#webpage",
"url": "https://example.com/about",
"name": "About Example Early Learning",
"about": { "@id": "https://example.com/#org" },
"mainEntity": { "@id": "https://example.com/about#founder" }
}
]
}
</script>
The founder’s name appears here and only here. A branch page that wants to mention the founder does so in visible copy, or adds "founder": {"@id": "https://example.com/about#founder"} on the branch node. It never types the name into a second Person node, because a second Person with the same name and no @id is a second person.
Step 6. Remove the old blocks and check the CMS is not adding its own
Delete every legacy Organization block from the page templates. Then look at what the SEO plugin is emitting, because on WordPress both Yoast SEO and Rank Math generate their own schema graph with an Organization node in it, and if that stays on while your graph goes live you have two declarations of the group on every page and you are back to Step 1. Yoast’s output can be switched off site-wide from a theme’s functions.php with one filter.
add_filter( 'wpseo_json_ld_output', '__return_false' );
Rank Math exposes a rank_math/json_ld filter that can be used the same way to return an empty array. Whichever plugin is installed, either switch its JSON-LD output off entirely or make the plugin the single source and give it the same @id and the same NAP source, and never let both run. Deploy, then clear the page cache and the CDN cache, since a cached branch page will keep serving the old block to a crawler for as long as the cache lives. Confirm with curl -s https://example.com/locations/rosewood | grep -c 'application/ld+json', which should print 1.
Step 7. Validate three ways
Each check catches a different class of error.
First, Google’s Rich Results Test at https://search.google.com/test/rich-results, run against the live URL of one branch page. This tells you whether Google sees the page as eligible for a rich result and reports required or recommended fields it thinks are missing. It is a per-page eligibility check and says nothing about whether the graph across pages is coherent, so a site with nine conflicting Organizations can pass it on every page.
Second, the Schema Markup Validator at https://validator.schema.org/, run against the homepage, one branch page and the about page. This checks the markup against the schema.org vocabulary itself, so it flags a property that ChildCare does not accept or a type name spelled wrong, and it renders the nodes so you can see where a reference appears as a bare @id and where a node is declared in full.
Third, the ledger, which is python3 idledger.py urls.txt run against the full URL list. It fetches every page, records every declared and referenced @id, and prints FIX for any @id declared zero times or more than once. The inventory on the finished site should show one EducationalOrganization, one ImageObject, eight ChildCare nodes, one Person and one AboutPage, each on its own page, and no (no @id) rows except the address, geo, hours and any breadcrumb lists you have added separately.
Check it worked
python3 idledger.py urls.txtends withRESULT: PASS, and the LEDGER section showshttps://example.com/#orgdeclared on the homepage and referenced from nine other pages (eight branches and the about page),https://example.com/#logodeclared on the homepage and referenced from eight,https://example.com/about#founderdeclared on the about page and referenced from one, and each branch @id declared on its own page and referenced from one, the homepage.- The INVENTORY section shows exactly one telephone string format and exactly one group name across all rows.
- The Schema Markup Validator shows zero errors on the homepage, a branch page and the about page, and on the branch page it shows
parentOrganizationas a single node with only an @id. - The Rich Results Test on a branch page reports the page as eligible for the LocalBusiness result with no missing required fields. Any warning about recommended fields refers to something you chose to omit, such as
aggregateRating, and is a decision rather than a defect. - In Search Console, after the next crawl of the branch pages, the Enhancements report lists the branch URLs as valid items, and the count of valid items stops changing between crawls. Give this [VERIFY: number of days until Search Console reflected the change] before reading anything into it.
Where I could be wrong
Google’s documentation does not promise that it stitches @id references across pages into one entity. It is possible that a bare {"@id": "https://example.com/#org"} on a branch page adds nothing beyond what parentOrganization with a name string would add, and that the gain on this site came from removing eight contradictory declarations rather than from cross-page linking. The ledger check is worth running either way, because a site that passes it cannot be contradicting itself.
Some practitioners repeat a minimal declaration of the parent, an @id plus @type plus name, on every branch page so that each page is self-describing to a per-page parser. I chose declared once and referenced elsewhere because it keeps one source of truth, but if a branch page’s rich result shows no parent name at all, adding the name to the reference is a reasonable experiment, and the ledger script would then need to allow a name on a reference.
I put sameAs only on the group. A branch with its own maintained social profile should carry its own sameAs for that profile, and the founder, who has no sameAs in the example above, would benefit from one pointing at a LinkedIn or Wikidata entry if such an entry exists.
The type choice is a judgment. ChildCare fits a daycare that also runs a preschool programme, but a group registered and marketed as a school might be better served by Preschool for each branch, which would change the LocalBusiness eligibility in the Rich Results Test.
Sources
- schema.org, ChildCare type definition. https://schema.org/ChildCare
- schema.org, EducationalOrganization type definition. https://schema.org/EducationalOrganization
- schema.org, LocalBusiness type definition. https://schema.org/LocalBusiness
- schema.org, parentOrganization and subOrganization properties. https://schema.org/parentOrganization and https://schema.org/subOrganization
- schema.org, founder property. https://schema.org/founder
- W3C, JSON-LD 1.1, section on node identifiers. https://www.w3.org/TR/json-ld11/#node-identifiers
- Google Search Central, Local business (LocalBusiness) structured data. https://developers.google.com/search/docs/appearance/structured-data/local-business
- Google Search Central, Organization structured data. https://developers.google.com/search/docs/appearance/structured-data/organization
- Google Search Central, General structured data guidelines. https://developers.google.com/search/docs/appearance/structured-data/sd-policies
- Google, Rich Results Test. https://search.google.com/test/rich-results
- Schema Markup Validator. https://validator.schema.org/