Updating hreflang Sets After a Domain Change
Problem Statement
hreflang annotations contain absolute URLs, which means a domain change invalidates every one of them simultaneously — even when the path structure is completely unchanged and nothing about the site’s shape has moved. That makes this the international migration failure teams most often miss: there is no structural change to prompt anybody, the pages look correct, and the annotations quietly point at a hostname that now only redirects. An alternate that redirects does not count as an alternate, so the sets stop being reciprocal, search engines stop treating the pages as translations of one another, and each locale is left competing with the others as an apparent duplicate. This page sits under International & hreflang Migrations.
When to Use This Approach
- The hostname is changing and the site carries
hreflangannotations in any form. - Locale paths are staying the same, so nothing else obviously needs attention.
- The estate spans enough locales that hand-editing sets is not realistic.
- Annotations are delivered inconsistently — some in markup, some in the sitemap.
- You need evidence of reciprocity across the whole graph rather than a sample.
Step-by-Step Instructions
1. Export the Existing Graph Before Anything Changes
Crawl the published estate and record, for every URL, the full list of alternates it declares. This is the only complete description of the current graph, and it stops existing the moment the domain moves. Take it from rendered pages rather than from the content system, because the CMS records which translations are configured while the crawl records which annotations are actually emitted.
# Per-URL alternate declarations, as published
grep -ohE '<link rel="alternate" hreflang="[^"]+" href="[^"]+"' ./crawl/*.html \
| sed 's/.*hreflang="//;s/" href="/,/;s/"$//' | sort -u > hreflang-graph.csv
wc -l hreflang-graph.csv
Capture the delivery mechanism alongside the annotations themselves. A site can carry them in markup, in Link headers, and in the sitemap simultaneously, and a regeneration that fixes one source while leaving another stale produces a graph that contradicts itself. Recording which mechanism each set uses turns that from a discovery you make afterwards into an input to the generation step.
2. Check Reciprocity on the Old Estate First
A meaningful share of large international sites already have broken sets before any migration begins. Establishing which ones were failing beforehand is what stops you attributing a pre-existing fault to your own change, and what stops you spending the migration window fixing something that has never worked.
# For each set member, confirm it is listed by every other member
awk -F, '{print $2}' hreflang-graph.csv | sort | uniq -c | sort -n | head
# Members appearing fewer times than the set size are one-way references
3. Rewrite the Graph From the Redirect Mapping
Generate the new annotations by joining the old graph to the old-to-new URL mapping, rather than by editing the existing markup. Every alternate URL becomes its post-migration equivalent, including the self-reference, and the whole set is emitted in one operation so symmetry is guaranteed by construction rather than by review.
# Rewrite every href through the migration mapping, keeping the set intact
import csv
mapping = {r['old']: r['new'] for r in csv.DictReader(open('redirect-map.csv'))}
rows = list(csv.reader(open('hreflang-graph.csv')))
out = [(lang, mapping.get(url, url)) for lang, url in rows] # unmapped stays put
csv.writer(open('hreflang-graph-new.csv', 'w')).writerows(out)
Preserve unmapped URLs rather than dropping or guessing them. A URL that appears in the old graph but not in the redirect mapping is an anomaly worth surfacing — usually a locale page that was retired without anybody updating its siblings — and passing it through unchanged makes it visible in the verification sweep instead of hiding it behind a plausible-looking substitution.
4. Verify Every Alternate Returns 200
An annotation pointing at a URL that redirects is discarded, so the new graph is only valid if every URL in it resolves directly on the new domain. Sweep the whole set rather than sampling, because the members that fail are disproportionately the ones nobody thought about.
# Any status other than 200 invalidates that member of the set
awk -F, '{print $2}' hreflang-graph-new.csv | sort -u | while read u; do
printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"
done | grep -v '^200 '
5. Ship the Annotations With the Redirects
Deploy both halves in the same release. Shipping the redirects first leaves every annotation pointing at a URL that now redirects; shipping the annotations first leaves them pointing at URLs that do not yet serve. Both orderings produce a window in which no set is valid, and the duration of that window is the duration of the damage.
# Confirm after deploy that a page references itself on the new domain
curl -s https://newdomain.com/de/preise/ \
| grep -o 'hreflang="de" href="[^"]*"'
# Expect the new hostname, not the legacy one
Worked Example
A retailer moves from oldbrand.com to newbrand.com, keeping every path identical. The redirect map is a single host-level rule and the site works perfectly on launch day. Nobody touches the annotations, because nothing about the structure has changed.
Six weeks later the German and French locales have lost roughly a third of their impressions while English is flat. The cause is visible in one request:
$ curl -s https://newbrand.com/de/preise/ | grep -o 'href="https://oldbrand.com[^"]*"' | head -2
href="https://oldbrand.com/en/pricing/"
href="https://oldbrand.com/de/preise/"
Every annotation still names the old host. Each alternate 301s to the new domain, so none of them counts, and the sets have been invalid since launch — with the smaller locales suffering most because they were relying on the association with the primary language. Regenerating the graph through the redirect mapping and deploying it restores reciprocity; recovery then takes as long as re-crawling every member, which is another five weeks.
Coordinate the release explicitly where the annotations and the redirect rules are owned by different teams. In practice the reliable arrangement is that both are generated by the same build from the same mapping and reviewed in one change — which is far easier to agree before the window than to negotiate during it.
Verification
Confirm the whole graph, not a sample, before declaring the change complete.
# 1. No annotation anywhere still references the legacy hostname
grep -rc 'hreflang=.*oldbrand\.com' ./crawl-new/ | grep -v ':0' | wc -l
# 2. Every alternate URL returns 200 on the new domain
awk -F, '{print $2}' hreflang-graph-new.csv | sort -u \
| xargs -I{} sh -c 'printf "%s {}\n" "$(curl -s -o /dev/null -w "%{http_code}" {})"' \
| grep -cv '^200 '
# 3. Every page includes a self-referencing annotation
curl -s https://newbrand.com/de/preise/ | grep -c 'href="https://newbrand.com/de/preise/"'
A clean result is zero files referencing the legacy host, zero non-200 alternates, and a self-reference present on every sampled page.
FAQ
Do the annotations really need to change if the paths are identical? Yes, without exception. The annotation is an absolute URL, so the hostname is part of it, and a set whose members all name the previous host describes a graph of pages that now only redirect. This is counter-intuitive precisely because nothing structural has moved, which is why it is the most commonly missed step in a domain change.
Can I leave the old annotations and rely on the redirects?
No. A redirecting URL is not treated as a valid alternate — the requirement is a direct 200 — so the redirects that correctly carry visitors and equity do nothing at all for the annotation graph. The two mechanisms solve different problems and neither substitutes for the other.
What if some locale pages have no equivalent on the new domain?
Drop them from the set rather than pointing at a substitute. A set with four valid members is healthy; a set with five members one of which 404s is not, and the invalid entry can cause the whole set to be disregarded. Where a locale genuinely retires, remove it from every other member’s annotations in the same generation pass.
How do I handle annotations that live in the sitemap rather than in markup? Identically, and remember to regenerate the sitemap in the same release. Sitemap-delivered annotations are the ones most often forgotten during a domain change because they are not visible when inspecting a page, and a site that carries annotations in both places needs both regenerated or the two sources will disagree.
How long does recovery take once the graph is fixed? As long as re-crawling every member, which for a large estate is weeks. There is no mechanism to accelerate it meaningfully beyond submitting the affected URLs in a refreshed sitemap. That slow repair against an instantaneous break is the whole argument for shipping the annotations in the same release as the redirects rather than as a follow-up.
Related
← Back to International & hreflang Migrations