DEV Community

Hanin Mp
Hanin Mp

Posted on AI-assisted

Hreflang return links: audit them with a short Python script and no dependencies

Hreflang fails quietly. When two language pages do not point at each other, Google does not show an error in your browser or in the page. The annotation simply does not do its job, and the first sign is the wrong language version ranking in the wrong country.

This tutorial builds a small checker that fetches one page, follows the alternates it lists, and reports missing return links, missing self-references, malformed codes and relative URLs. It uses only the Python standard library.

What the rules actually say

Everything below comes from Google's page on localized versions. I read it in full before writing the code.

  • Every version must list itself and all other versions, including the default page.
  • Alternate URLs must be fully qualified, with the protocol. A root-relative path like /de/ is not enough.
  • Links must be reciprocal. If page X points to page Y, page Y must point back to X, otherwise Google may ignore or misread the annotations.
  • x-default is for a language selector or a fallback for unmatched languages.
  • The language part is ISO 639-1 and the optional region part is ISO 3166-1 Alpha 2. A country code on its own is not valid, because the first code must be a language.
  • Reserved region codes such as EU, UN and UK are ignored. This catches the common en-UK, where the correct region for the United Kingdom is GB.
  • HTML <link> tags, an HTTP Link header and an XML sitemap are treated as equivalent. Pick one.

This script checks the HTML method only. If your site uses sitemap annotations, the same logic applies, but you would parse xhtml:link entries instead.

Step 1: read the annotations from the head

Hreflang tags belong in the <head>. The parser below collects rel="alternate" links that carry an hreflang attribute, and stops reading once the <body> starts, so a stray link in the body is not counted.

class HreflangParser(HTMLParser):
    """Collect <link rel="alternate" hreflang=...> from the <head> only."""

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.entries = []
        self._in_head = False
        self._done = False

    def handle_starttag(self, tag, attrs):
        if self._done:
            return
        if tag == "head":
            self._in_head = True
        elif tag == "body":
            self._done = True
        elif tag == "link" and self._in_head:
            a = {k.lower(): (v or "") for k, v in attrs}
            rels = a.get("rel", "").lower().split()
            if "alternate" in rels and a.get("hreflang"):
                self.entries.append((a["hreflang"].strip(), a.get("href", "").strip()))

    def handle_endtag(self, tag):
        if tag == "head":
            self._done = True
Enter fullscreen mode Exit fullscreen mode

Splitting rel on whitespace matters, because rel="alternate home" is valid HTML. Lowercasing attribute names handles HREFLANG= written in uppercase.

Step 2: check each code

RESERVED_REGIONS = {"EU", "UN", "UK"}
CODE_RE = re.compile(r"^([a-zA-Z]{2})(?:-([a-zA-Z]{4}))?(?:-([a-zA-Z]{2}))?$")


def check_code(code):
    """Return a list of problems with one hreflang value (empty if fine)."""
    if code.lower() == "x-default":
        return []
    m = CODE_RE.match(code)
    if not m:
        return [f"malformed code '{code}'"]
    region = m.group(3)
    if region and region.upper() in RESERVED_REGIONS:
        return [f"reserved region '{region.upper()}' in '{code}' is ignored by Google"]
    return []
Enter fullscreen mode Exit fullscreen mode

The pattern accepts a two-letter language, an optional four-letter script and an optional two-letter region. That catches en_GB (underscore) and english. It does not catch a country code used on its own. A bare us is two letters, so it passes the shape check even though Google needs a language first. The script does not carry the full ISO 639-1 and ISO 3166-1 lists, so it cannot tell a real language from an unused two-letter pair. Add those lists if you need that guarantee.

Step 3: audit the return links

This is the core. It compares every page against every alternate it lists.

def audit(pages):
    """pages: {url: [(hreflang, href), ...]}. Returns a list of issue dicts."""
    issues = []
    lookup = {norm(u): {norm(h) for _, h in e if h} for u, e in pages.items()}
    # targets listed with a real language code, excluding x-default entries
    lang_lookup = {
        norm(u): {norm(h) for c, h in e if h and c.lower() != "x-default"}
        for u, e in pages.items()
    }

    for url, entries in pages.items():
        if not entries:
            issues.append({"url": url, "level": "info", "msg": "no hreflang annotations"})
            continue
        codes = [c.lower() for c, _ in entries]
        for code, href in entries:
            for problem in check_code(code):
                issues.append({"url": url, "level": "error", "msg": problem})
            if not re.match(r"^https?://", href):
                issues.append({"url": url, "level": "error",
                               "msg": f"'{code}' href is not fully qualified: '{href}'"})
        for c in sorted({c for c in codes if codes.count(c) > 1}):
            issues.append({"url": url, "level": "error", "msg": f"duplicate hreflang '{c}'"})
        if norm(url) not in {norm(h) for _, h in entries}:
            issues.append({"url": url, "level": "error", "msg": "page does not list itself"})
        if "x-default" not in codes:
            issues.append({"url": url, "level": "warning", "msg": "no x-default entry"})
        for code, href in entries:
            target = norm(href)
            if target == norm(url) or target not in lookup:
                continue  # self, or a page we did not audit
            if norm(url) not in lookup[target]:
                issues.append({"url": url, "level": "error",
                               "msg": f"missing return link: {href} ({code}) does not list this page"})
            elif norm(url) not in lang_lookup[target] and code.lower() != "x-default":
                issues.append({"url": url, "level": "warning",
                               "msg": f"{href} links back only through x-default, "
                                      f"with no language-coded entry for this page"})
    return issues
Enter fullscreen mode Exit fullscreen mode

Three decisions are worth explaining.

  1. A page that was not audited is skipped. If page A lists a page the script never fetched, it cannot judge reciprocity, so it stays silent instead of guessing.
  2. A return link that exists only through x-default is a warning, not an error. Google's documentation says links must be reciprocal but does not say whether an x-default entry counts as the link back. A page that points back at you only through x-default has no language-coded entry for you, so I flag it for a human to judge.
  3. Trailing slashes are normalized. https://example.com/de and https://example.com/de/ are treated as the same address when matching, which avoids false return-link errors. If your server treats them as different URLs, that is a separate canonicalization problem.

collect() in the full file fetches the seed page, then each alternate it lists, once each, up to a cap of 30 pages by default. A failed fetch is recorded and the run continues.

Run it

python hreflang_check.py https://example.com/en/ --max-pages 30
Enter fullscreen mode Exit fullscreen mode

The script exits with status 1 if any error is found, so you can run it in CI after a deploy.

Example output

I ran the audit on three synthetic pages, built to be wrong in a few ways. The English page lists everything. The German page lists only German and French, and the French page uses en-UK with a relative URL. This is the real output of that run, not a live site:

Pages fetched: 3
[error] https://example.com/en/: missing return link: https://example.com/de/ (de) does not list this page
[warning] https://example.com/de/: no x-default entry
[error] https://example.com/fr/: reserved region 'UK' in 'en-UK' is ignored by Google
[error] https://example.com/fr/: 'en-UK' href is not fully qualified: '/uk/'
Enter fullscreen mode Exit fullscreen mode

Notice that the missing return link is reported on the English page, not the German one. The English page claims a German alternate, and the German page does not point back. That is the direction to fix: add the English entry to the German page.

How it was tested

Thirteen offline tests use synthetic HTML strings and a fake fetcher, so they need no network:

pip install pytest
pytest -q test_hreflang_check.py
Enter fullscreen mode Exit fullscreen mode

They cover head-only parsing, mixed-case attributes and multi-token rel, a clean set with no errors, a missing return link, a missing self-reference, a page whose x-default points at itself, a return link that exists only through x-default, code shapes including en_GB and en-UK, relative URLs, duplicate codes, a missing x-default being a warning, trailing-slash matching, fetching each page once with a failing page, and the page cap. All 13 passed.

One test failed on my first run. The cause was my test data, not the code: I had written a page whose x-default pointed back at itself and expected a "does not list itself" error, but that page does appear in its own list. I fixed the test and added one that pins the behavior down.

What this does not do

  • No live testing. I did not run it against a real site, so I cannot say how it behaves with redirects, bot protection, large international sites or slow servers.
  • Shape checks only for codes. As noted above, unknown but well-formed codes pass.
  • HTML method only. HTTP Link headers and sitemap annotations are not read.
  • It does not render JavaScript. If your hreflang tags are injected client-side, the script will not see them. That is also worth knowing, because a crawler reading raw HTML will not see them either.
  • Canonicals are not checked. Hreflang URLs should point to indexable canonical versions. A good next step is to extend the script to compare each alternate's canonical.

Sources

About the author

I am an SEO specialist working on technical SEO, GEO, AEO, entities and international SEO, with 5+ years of experience across 32+ websites, 6 markets and 12+ industries. I write about the parts of SEO that can be checked with a script.

Top comments (0)