Hreflang mistakes that quietly break international targeting
Hreflang fails silently: a broken set looks fine on the page you check. Ten mistakes, a worked example, and a short script that finds the ones you cannot see.
What hreflang does, and what it does not
Hreflang tells search engines that several URLs are the same page for different audiences, so the right one can be shown to the right searcher. It is a hint, not an instruction. It does not make a page rank higher and it does not get a page indexed; it only helps choose between pages that already exist and already say the same thing.
That is why it matters most for same-language regional versions. An en-US, en-GB and en-AU page may be near-identical, and without hreflang they compete with each other. Pages in entirely different languages are usually separated by the language of the query alone, so mistakes there cost less, but they still cost something.
What a correct set looks like
Take a pricing page published for US English, UK English and German, with the US page as the fallback. Every one of the three pages carries the same four lines in its <head>:
<link rel="alternate" hreflang="en-us" href="https://example.com/us/pricing/" />
<link rel="alternate" hreflang="en-gb" href="https://example.com/uk/pricing/" />
<link rel="alternate" hreflang="de" href="https://example.com/de/preise/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/us/pricing/" />
Three details are easy to miss. Each page lists itself as well as the others. The URLs do not have to match across languages (the German slug is preise). And x-default names the page to use for everyone who matches none of the others; it may point to the same URL as one of the versions.
The cost of getting this right by hand grows fast. Every page carries one tag per version, so n versions means n × n annotations: 3 versions is 9, 10 versions is 100, plus an x-default line on each page. That is the reason sets drift out of sync, and the reason to generate them from one list rather than edit them page by page.
The mistakes
1. Missing return links. If page A names page B as its alternate, B must name A back. A one-way claim is ignored, because otherwise any site could declare itself the translation of any other. The usual cause is a new language version that was added to its own pages but never to the existing ones.
2. No self-reference. A page that lists its translations but not itself is treated as an incomplete set.
3. Invalid codes. The language is a two-letter ISO 639-1 code and the optional region is a two-letter ISO 3166-1 Alpha-2 country code. So en-GB, not en-UK, and there is no code for the EU. A region cannot stand alone: gb or us by itself is invalid. Codes such as english or eu are simply unrecognised.
4. Relative or mismatched URLs. Annotations need fully qualified URLs, including https://. They must also match the URL as it is really served and canonicalised: http versus https, www versus bare domain, and trailing slash versus none all count as different addresses.
5. Targets that redirect, 404 or are noindexed. Every URL in the set should answer 200 directly and be allowed into the index. This is the classic post-migration failure: slugs change, the annotations keep the old ones, and the set quietly stops pairing.
6. Canonical conflicts. Each version should be canonical to itself. If a German page carries a canonical pointing at the English original, it is telling Google it is a duplicate, and its hreflang is not going to be trusted. The URLs in the annotations should be the canonical ones, not parameterised variants.
7. Two URLs for one code. If both /en/ and /en-us/ are labelled en in the same set, the set contradicts itself. Each language-and-region code gets exactly one URL.
8. Methods that disagree. Tags in the HTML, entries in the sitemap and HTTP headers are all valid, and they are interchangeable. Using more than one for the same pages invites a version where they differ. Pick one place and generate it from a single source.
9. Pairing pages that are not equivalents. Hreflang links a page to its own translation. If the German site has no pricing page, leave German out of the English pricing page’s set; do not point it at the German homepage to fill the gap.
10. Redirecting visitors by location or language. An automatic redirect based on IP address or browser language can stop the crawler from ever seeing the other versions, because it usually crawls from one place with one set of headers. Offer a visible language switcher and let hreflang do the routing in search results.
Finding the errors you cannot see
A broken hreflang set is almost never broken on the page you are looking at. The page looks complete; the fault is that one of the other pages does not point back. Search Console’s old International Targeting report, which used to list these errors, has been retired, so there is nothing built in that does the check for you.
The check itself is mechanical: read the annotations on one page, fetch each alternate, and confirm it lists the first page in return. This short script does exactly that, using only Python’s standard library:
import sys
import urllib.request
from html.parser import HTMLParser
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.found = {}
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "link" and a.get("rel") == "alternate" and a.get("hreflang"):
self.found[a["hreflang"].lower()] = a.get("href", "")
def hreflang_set(url):
req = urllib.request.Request(url, headers={"User-Agent": "hreflang-check"})
html = urllib.request.urlopen(req, timeout=15).read().decode("utf-8", "replace")
parser = Links()
parser.feed(html)
return parser.found
start = sys.argv[1]
found = hreflang_set(start)
print(f"{start} declares {len(found)} alternates")
if start not in found.values():
print(" MISSING self-reference")
for code, href in found.items():
if href == start:
continue
back = hreflang_set(href).values()
print(f" {code:10} {href} {'ok' if start in back else 'NO RETURN LINK'}")
Save it as check_hreflang.py and run it with the page you want to audit:
python3 check_hreflang.py https://example.com/uk/pricing/
An illustrative run on a set with one broken return link looks like this:
https://example.com/uk/pricing/ declares 4 alternates
en-us https://example.com/us/pricing/ ok
de https://example.com/de/preise/ NO RETURN LINK
x-default https://example.com/us/pricing/ ok
The script reads the tags in the HTML only, so it does not cover sets declared in a sitemap or in headers. It also follows redirects without telling you, so run it on the final URL. Loop it over your most important pages rather than trusting one.
Before you ship a set
- Every page lists itself and every other version, with the same set on every page.
- Codes are
languageorlanguage-COUNTRY, in the ISO forms above. - All URLs are absolute, use the served protocol and host, and answer 200 without a redirect.
- Each version is canonical to itself and indexable.
- One method, generated from one list.
- Nothing in
robots.txtblocks a language version: a blocked page can never confirm its return link. The robots.txt tester shows which rule wins for a given path, and how robots.txt really works explains why. - Translated titles and descriptions fit their own snippets, since the same message can run noticeably wider in another language. The SERP snippet preview measures each in pixels, and why pixels rather than characters explains the arithmetic.
The hreflang generator builds the tags, the sitemap form or the headers from a single list, and flags invalid and duplicate codes, relative URLs and a missing x-default as you type. What it cannot do is look at your live pages, which is the job of the script above.
More guides
- How to remove VAT from a price, and why subtracting the percentage is wrong
- How robots.txt really works: which rule wins, and what it cannot do
- Why Google cuts your title by pixels, not characters
- ACOS to ROAS: how to convert, and the margin check that decides if you're profitable
- How to calculate CPM: formula, worked examples and reverse maths