DEV Community

seotto.com
seotto.com

Posted on Edited on

How to check hreflang tags for errors (with a script)

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" />
Enter fullscreen mode Exit fullscreen mode

And /de/preise must carry the same set, pointing back.

The common mistakes

  1. Missing return links. Page A says B is its German version, but B doesn't list A. Search engines may ignore the pair.
  2. Invalid codes. en-UK instead of en-GB, jp instead of ja. Language codes are ISO 639-1; regions are ISO 3166-1 alpha-2.
  3. Pointing to redirects or 404s. Every href should return 200 directly.
  4. Pointing to non-canonical URLs. If the target page's canonical points elsewhere, the signals conflict.
  5. Relative URLs. Use absolute URLs including the protocol.
  6. 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])
Enter fullscreen mode Exit fullscreen mode
python3 hreflang.py https://example.com/en/pricing
Enter fullscreen mode Exit fullscreen mode

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()]}?")
Enter fullscreen mode Exit fullscreen mode

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-default is 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.

Related reading

Top comments (0)