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 hreflang annotations 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
What a domain change does to each part of an annotation The components of an hreflang annotation compared by whether a hostname change affects them, showing that the href is invalidated while everything else is untouched. Only one field changes — and it is the one that matters Component Example Affected by a host change? hreflang code de, fr, en-GB no rel attribute alternate no href URL https://old.com/de/ yes — every one x-default target https://old.com/en/ yes Two of four fields are invalidated wholesale, which is why the whole graph is regenerated rather than patched.
Nothing in the markup looks wrong afterwards — the URLs are well-formed, they simply point at a host that redirects.

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.

Regenerating the graph through the redirect mapping The exported old graph joined to the old-to-new redirect mapping to produce a new graph, verified for reciprocity and status, then deployed alongside the redirects themselves. Join the graph to the mapping — never edit the markup Old graph crawled, as published Redirect map old to new URL New graph generated, symmetric Ship together one release Generating from a join guarantees the property that hand-editing cannot: every member references every member, including itself. URLs absent from the mapping stay as they are, which surfaces them as anomalies rather than silently rewriting them wrongly.
The mapping is the same artefact the redirect rules are generated from, so the annotations and the routing cannot disagree.

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.

Symptoms of a broken graph, by locale size How the impact of an invalid alternate set is distributed across locales, with the largest locale affected least and smaller markets losing the association that supported them. The smallest locales lose the most, and complain last Locale Relies on the set for Typical impact Primary language little — ranks on its own flat Large secondary market disambiguation from the primary moderate decline Small market most of its association sharp decline Regional variant (en-GB vs en-US) almost everything one variant absorbs the other The last row is the clearest signal: two variants of one language collapsing into each other is nearly always a reciprocity failure.
Because the primary locale is unaffected, aggregate traffic can look healthy while several markets are quietly losing ground.

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