If your site has several language or country versions, hreflang tags tell search engines which version to show to which audience. They're also easy to get wrong in ways that don't show up anywhere visible: the page looks fine, but the tags are ignored. Here are the common mistakes and a script to find them.
What correct hreflang looks like
Each language version lists every version, including itself:
<!-- on https://example.com/en/pricing -->
<link rel="alternate" hreflang="en" href="https://example.com/en/pricing" />
<link rel="alternate" hreflang="de" href="https://example.com/de/preise" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/pricing" />
And /de/preise must carry the same set, pointing back.
The common mistakes
- Missing return links. Page A says B is its German version, but B doesn't list A. Search engines may ignore the pair.
-
Invalid codes.
en-UKinstead ofen-GB,jpinstead ofja. Language codes are ISO 639-1; regions are ISO 3166-1 alpha-2. - Pointing to redirects or 404s. Every href should return 200 directly.
- Pointing to non-canonical URLs. If the target page's canonical points elsewhere, the signals conflict.
- Relative URLs. Use absolute URLs including the protocol.
- No self-reference. Each page should list itself.
A checker script
#!/usr/bin/env python3
import sys, requests
from html.parser import HTMLParser
UA = {"User-Agent": "Mozilla/5.0 (hreflang check)"}
class Alts(HTMLParser):
def __init__(self):
super().__init__(); self.alts, self.canonical = {}, None
def handle_starttag(self, tag, attrs):
if tag != "link": return
a = dict(attrs); rel = (a.get("rel") or "").lower()
if rel == "alternate" and a.get("hreflang"):
self.alts[a["hreflang"]] = a.get("href", "")
if rel == "canonical":
self.canonical = a.get("href")
def read(url):
r = requests.get(url, headers=UA, timeout=20, allow_redirects=False)
p = Alts()
if r.status_code == 200:
p.feed(r.text)
return r.status_code, p.alts, p.canonical
def check(start):
status, alts, canonical = read(start)
if status != 200:
print(f"start page returned {status}"); return
if start not in alts.values():
print("missing self-reference")
for code, href in alts.items():
if not href.startswith("http"):
print(f"{code}: relative URL {href}")
continue
s, their_alts, their_canon = read(href)
if s != 200:
print(f"{code}: {href} returned {s}")
continue
if their_canon and their_canon != href:
print(f"{code}: canonical of {href} is {their_canon}")
if start not in their_alts.values():
print(f"{code}: {href} has no return link to {start}")
print(f"checked {len(alts)} alternates")
check(sys.argv[1])
python3 hreflang.py https://example.com/en/pricing
allow_redirects=False is deliberate: a redirect is reported as a problem rather than silently followed.
Validating the codes
The script doesn't validate codes. A quick extra check with a small list catches the usual typos:
COMMON_MISTAKES = {"en-uk": "en-GB", "jp": "ja", "cn": "zh", "gr": "el", "se": "sv", "dk": "da"}
for code in alts:
if code.lower() in COMMON_MISTAKES:
print(f"{code}: did you mean {COMMON_MISTAKES[code.lower()]}?")
For full validation, check the language part against ISO 639-1 and the region against ISO 3166-1.
Sitemaps instead of HTML
You can also declare hreflang in the XML sitemap with xhtml:link elements. The same rules apply: every URL lists all alternates, including itself. Choose one method. Mixing HTML tags and sitemap entries makes conflicts more likely.
A quick checklist
- [ ] Every page lists all its language versions, including itself
- [ ] Every listed URL returns 200 without redirecting
- [ ] Every target page links back
- [ ] Codes are valid ISO language (and optional region) codes
- [ ] Each target's canonical points to itself
- [ ] An
x-defaultis set if you have a language picker or global fallback
Run through it for one page in each template type (home, category, article, product). Hreflang problems are usually template problems, so one broken example often means all pages of that type are affected.
Keep it from breaking again
Hreflang breaks when a page is renamed in one language only, when a translation is unpublished, or when a template change drops the tags. Run the check after deploys that touch localized templates. Ongoing crawls that compare canonicals and status codes, like SEOtto does, will also surface the redirects and canonical changes that quietly break hreflang pairs.
Top comments (0)