hreflang tells Google that a set of pages are language or regional versions of each other, so it can show a German searcher the German page and a UK searcher the UK page. It is also one of the easiest things in technical SEO to get quietly wrong. The German page forgets to list itself. The French page links to the German one, but the German one never links back. Someone writes en-UK because "UK" feels right, or en_US because that is what the CMS locale setting uses. A translated page gets deleted, and five other pages keep pointing at a 404.
None of it is visible in a browser, and a single page checker only sees one side of each pair. This post builds a small auditor in Python using only the standard library (urllib, html.parser, xml.etree, csv, concurrent.futures). It reads a sitemap or sitemap index, picks up hreflang from all three places Google accepts it (sitemap xhtml:link entries, the HTML <head> and the Link HTTP header), fetches every page and every alternate, and checks the whole cluster: return links, self-references, language and region codes, x-default, relative URLs, duplicates and broken alternates. Output is a CSV plus a console summary, and the exit code works in CI.
Google Search Central's Tell Google about localized versions of your page (showing "Last updated 2026-09-21 UTC") was opened on 11 Nov 2026, and every rule quoted below comes from that page. Appie free-tool descriptions are as of 11 Nov 2026. The script was tested on Python 3.13.5 against a local fixture site and a live public sitemap; the output below is real.
Disclosure: Appie.run publishes this article. Two free tools mentioned near the end are ours, and one of the test runs below used our own sitemap. The script does not depend on any of them.
Where this fits
This is the next script in a small standard-library series. Earlier posts covered validating an XML sitemap and finding broken links and redirect chains in a sitemap. This one is for sites with more than one language or region. If your site is in one language only, the script will just tell you there is nothing to check, which is what happened on our own sitemap below.
What Google's documentation actually says
One page covers this, and these are the lines that turn into checks.
Three methods, all equivalent. You can annotate in HTML, in HTTP headers or in the sitemap. "The three methods are equivalent from Google's perspective." You can use all three at once, but "there's no benefit in Search," and it is harder to manage. So the script reads all three and tells you when they disagree for the same page.
hreflang is not language detection. Google says it does not use hreflang or the HTML lang attribute to detect a page's language; it uses algorithms. hreflang tells Google which pages are variations of the same content.
Every version lists itself and all the others. "Each language version must list itself as well as all other language versions." For the sitemap method, each <url> element must have xhtml:link children for every alternate, including itself.
Links have to go both ways. "If two pages don't both point to each other, the tags will be ignored." Google also says that if a complete set is hard to maintain, you can omit some languages on some pages and it will still process the pairs that point to each other. The "Common mistakes" section lists missing return links first.
Fully qualified URLs. https://example.com/foo, not //example.com/foo or /foo. Alternates do not need to be on the same domain.
Codes. The value is an ISO 639-1 language code, optionally followed by an ISO 3166-1 Alpha 2 region code. Codes outside those standards are not supported, and the page names es-419 as an example. Values are case-insensitive. A region by itself is not valid: the page's example is be, which is the Belarusian language code, not Belgium. Region codes that are reserved for something else, such as EU, UN or UK, have no effect, and Google ignores that part of the annotation. Scripts can be given with ISO 15924, as in zh-Hant or zh-Hans-US.
x-default is a recommendation, not a requirement. Google says to "consider adding a fallback page for unmatched languages," and that x-default "was designed for language selector pages." So a missing x-default is informational in this script.
A generic language page is a good idea. If you have en-IE, en-CA and en-AU, Google suggests also providing a generic en URL for English speakers elsewhere. It can be one of the regional pages.
HTML placement. The <link> elements must be inside a well-formed <head>, and you should not combine hreflang with other attributes such as media in a single <link>.
Notice what the page does not say. It does not say hreflang is a ranking factor, it does not say a missing x-default breaks anything, and it does not promise that a clean set of tags means Google will show the version you expect. The script checks whether your annotations follow the documented rules and agree with each other. Search Console's URL Inspection tool is still where you see how Google treated a specific URL.
What the script flags
| Flag | Meaning | What to check |
|---|---|---|
missing_return_link | This page lists an alternate, but the alternate does not list this page | Add the link back, or remove the one-way link |
missing_self_reference | The page's own URL is not in its hreflang set | Add a link to itself with its own code |
invalid_language | First subtag is not an ISO 639-1 language (gb, jp) | Use the language code (en, ja) |
invalid_region | Region is not an assigned ISO 3166-1 Alpha 2 code (en-UK, en-EU) | en-GB, or drop the region |
unsupported_region | Numeric region such as es-419 | Google does not support it; use es or specific countries |
invalid_code_format | Underscore or a shape that is not lang, lang-REGION, lang-Script or lang-Script-REGION | Usually a CMS locale (en_US) pasted in; use en-US |
relative_href | href is not fully qualified | Use an absolute URL with https:// |
duplicate_code | One code points to two different URLs | Keep one URL per code |
code_mismatch | Page A calls page B en-GB, but B calls itself something else | Make the set identical on every version |
alternate_error | An alternate returns 4xx/5xx or fails to load | Fix the URL or remove it from every page in the cluster |
alternate_redirects | An alternate redirects | Point at the final URL |
hreflang_in_body | An hreflang <link> appears after <body> | Move it into <head>; the script ignores it for the other checks |
hreflang_with_media | hreflang combined with media or type on one <link> | Split them; the script ignores that link for the other checks |
page_error | The sitemap URL itself did not load | Fix the page or remove it from the sitemap |
no_x_default | No x-default in the set (informational) | Optional; most useful on selector or auto-redirecting pages |
no_generic_language | Regional codes like en-IE with no plain en (informational) | Google suggests a generic page; one of the regional pages can be it |
sources_disagree | Sitemap, HTML and header give different sets for one page (informational) | Pick one method |
no_hreflang | No annotations on the page (informational) | Fine for single-language pages |
As in the earlier scripts, markup inside <script>, <style>, <template> and <noscript> is ignored, so tags added only by JavaScript will not be seen.
The script
Save as hreflang_audit.py. No pip install needed.
#!/usr/bin/env python3 """Audit hreflang annotations for every URL in a sitemap. Reads hreflang from sitemap xhtml:link entries, the HTML <head> and the Link response header. Standard library only. Usage: python3 hreflang_audit.py https://example.com/sitemap.xml --out hreflang_report.csv """ import argparse import csv import re import sys import urllib.error import urllib.request import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor from html.parser import HTMLParser from urllib.parse import urljoin, urlsplit, urlunsplit UA = "hreflang-audit/1.0 (+stdlib python)" SM_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" XHTML_LINK = "{http://www.w3.org/1999/xhtml}link" # ISO 639-1 language codes and ISO 3166-1 alpha-2 region codes # (snapshot from the Debian iso-codes 4.18.0 package; refresh as needed) LANGS = set(""" aa ab ae af ak am an ar as av ay az ba be bg bh bi bm bn bo br bs ca ce ch co cr cs cu cv cy da de dv dz ee el en eo es et eu fa ff fi fj fo fr fy ga gd gl gn gu gv ha he hi ho hr ht hu hy hz ia id ie ig ii ik io is it iu ja jv ka kg ki kj kk kl km kn ko kr ks ku kv kw ky la lb lg li ln lo lt lu lv mg mh mi mk ml mn mr ms mt my na nb nd ne ng nl nn no nr nv ny oc oj om or os pa pi pl ps pt qu rm rn ro ru rw sa sc sd se sg si sk sl sm sn so sq sr ss st su sv sw ta te tg th ti tk tl tn to tr ts tt tw ty ug uk ur uz ve vi vo wa wo xh yi yo za zh zu""".split()) REGIONS = set(""" AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW""".split()) INFO_ONLY = {"no_hreflang", "no_x_default", "no_generic_language", "sources_disagree"} def fetch(url, timeout=15): """GET a URL. Returns a dict; HTTP errors come back as status codes.""" req = urllib.request.Request(url, headers={"User-Agent": UA}) try: with urllib.request.urlopen(req, timeout=timeout) as resp: charset = resp.headers.get_content_charset() or "utf-8" return {"status": resp.status, "final_url": resp.geturl(), "headers": resp.headers, "body": resp.read().decode(charset, "replace")} except urllib.error.HTTPError as e: return {"status": e.code, "final_url": url, "headers": e.headers, "body": ""} except (urllib.error.URLError, TimeoutError) as e: return {"status": f"error:{e}", "final_url": url, "headers": None, "body": ""} def norm(url): """Lowercase scheme and host, drop default ports and fragments.""" p = urlsplit(url) host = (p.hostname or "").lower() if p.port and not ((p.scheme == "http" and p.port == 80) or (p.scheme == "https" and p.port == 443)): host += f":{p.port}" return urlunsplit((p.scheme.lower(), host, p.path or "/", p.query, "")) def read_sitemap(sitemap_url, seen=None): """Return [(loc, [(code, href), ...])] from a urlset, following indexes.""" seen = seen if seen is not None else set() if sitemap_url in seen: return [] seen.add(sitemap_url) r = fetch(sitemap_url) if r["status"] != 200: raise urllib.error.URLError(f"sitemap returned {r['status']}: {sitemap_url}") root = ET.fromstring(r["body"].encode("utf-8")) if root.tag == SM_NS + "sitemapindex": out = [] for sm in root.findall(SM_NS + "sitemap"): loc = sm.find(SM_NS + "loc") if loc is not None and loc.text: out.extend(read_sitemap(loc.text.strip(), seen)) return out out = [] for u in root.findall(SM_NS + "url"): loc = u.find(SM_NS + "loc") if loc is None or not loc.text: continue alts = [(x.get("hreflang", "").strip(), x.get("href", "").strip()) for x in u.findall(XHTML_LINK) if x.get("rel", "").lower() == "alternate" and x.get("hreflang")] out.append((loc.text.strip(), alts)) return out class HreflangParser(HTMLParser): """Collect <link rel="alternate" hreflang> elements and where they sit.""" def __init__(self): super().__init__(convert_charrefs=True) self.links = [] # dicts: code, href, in_head, extra self._skip = 0 self._in_head = True # until </head> or <body> def handle_starttag(self, tag, attrs): a = {k: (v or "") for k, v in attrs} if tag in ("script", "style", "template", "noscript"): self._skip += 1 return if self._skip: return if tag == "body": self._in_head = False elif (tag == "link" and "hreflang" in a and "alternate" in a.get("rel", "").lower().split()): self.links.append({"code": a["hreflang"].strip(), "href": a.get("href", "").strip(), "in_head": self._in_head, "extra": [k for k in ("media", "type") if k in a]}) def handle_startendtag(self, tag, attrs): self.handle_starttag(tag, attrs) def handle_endtag(self, tag): if tag in ("script", "style", "template", "noscript"): self._skip = max(0, self._skip - 1) elif tag == "head": self._in_head = False def link_header_hreflang(values): """Return [(code, href)] from Link headers with rel=alternate and hreflang.""" out = [] for v in values: for m in re.finditer(r"<([^>]*)>\s*((?:;[^,<]*)*)", v): params = m.group(2) rel = re.search(r'rel\s*=\s*"?([^";]+)"?', params, re.I) lang = re.search(r'hreflang\s*=\s*"?([^";\s]+)"?', params, re.I) if rel and lang and "alternate" in rel.group(1).lower().split(): out.append((lang.group(1), m.group(1).strip())) return out def code_problem(code): """Return an issue code for an invalid hreflang value, or None.""" if code.lower() == "x-default": return None if "_" in code or not re.fullmatch(r"[A-Za-z0-9-]+", code): return "invalid_code_format" parts = code.split("-") if parts[0].lower() not in LANGS: return "invalid_language" rest = parts[1:] if rest and re.fullmatch(r"[A-Za-z]{4}", rest[0]): # ISO 15924 script, e.g. Hant rest = rest[1:] if not rest: return None if len(rest) > 1: return "invalid_code_format" region = rest[0] if re.fullmatch(r"\d{3}", region): # es-419 and friends return "unsupported_region" if len(region) != 2: return "invalid_code_format" if region.upper() not in REGIONS: # UK, EU, UN, typos return "invalid_region" return None def collect(url, sitemap_alts): """Fetch one URL and gather its hreflang annotations from every source.""" r = fetch(url) page = {"url": url, "status": r["status"], "final_url": r["final_url"], "sources": {}, "issues": [], "detail": []} if sitemap_alts: page["sources"]["sitemap"] = sitemap_alts if r["headers"] is None: return page header = link_header_hreflang(r["headers"].get_all("Link") or []) if header: page["sources"]["header"] = header if "html" in (r["headers"].get_content_type() or "") and r["body"]: p = HreflangParser() p.feed(r["body"]) p.close() if any(not x["in_head"] for x in p.links): page["issues"].append("hreflang_in_body") if any(x["extra"] for x in p.links): page["issues"].append("hreflang_with_media") head = [(x["code"], x["href"]) for x in p.links if x["in_head"] and not x["extra"]] if head: page["sources"]["html"] = head return page def annotations(page): """Union of all sources: {(code_lower, absolute_url)}, plus raw pairs.""" pairs = [pair for src in page["sources"].values() for pair in src] base = page["final_url"] orSource:https://dev.to/appierun_66/audit-hreflang-tags-across-your-sitemap-with-python-standard-library-only-missing-return-links-2m8dRelated posts
Indexing a villa against itself: a median-of-ratios holiday price index in pandas

Synthetic Checks & SLOs for the Paths Nobody Clicks
Why a Mac Writing Helper Needs Two Permissions, and Why It Still Refuses Password Fields
Decoding iPhone HEIC photos in the browser: a 4-step fallback chain and what it costs

How to Stand Out When Every Application Looks the Same
