Validate the hreflang annotations of a multilingual site: language and region codes, return links, canonicals, redirects, <html lang>, and whether the HTML, the Link: headers and the sitemap agree with each other.
python -m hreflang_check https://example.com/en/ https://example.com/fr/
hreflang-check https://example.com/en/ --crawl --max-pages 20 --format json --output report.json
Why another checker:
- Three sources, compared. hreflang can live in
<link rel="alternate">in the<head>, in HTTPLink:headers, or in sitemapxhtml:linkentries (sitemap index and.gzincluded). All three are read and cross-checked, because a conflict between them is a real bug that single-source tools miss. - Checks the targets, not only the page. Every hreflang target is fetched: it must answer 200 without redirecting, and it must link back.
- Polite by design. Same host only,
robots.txthonoured with RFC 9309 matching (longest match wins,*and$; noturllib.robotparser, which applies the first match), at least 1 s between requests, timeouts, GET/HEAD only, hard cap of 30 pages, and it never submits a form. - No dependencies. Standard library only, Python 3.10+. The ISO 639-1 and ISO 3166-1 code lists are bundled.
| Check | Severity | What it looks for |
|---|---|---|
language-code |
error / warning | Invalid ISO 639-1 language or ISO 3166-1 region, case-insensitive, x-default accepted. Explains common mistakes: en-UK (use en-GB), en_US (underscore), eng (ISO 639-2), region-only codes such as us, jp for ja, deprecated iw |
return-link |
error | If A lists B, B must list A |
self-reference |
warning | The page lists itself |
x-default |
warning | No x-default in the set |
target-status |
error / info | Target does not return 200, or redirects (the chain is printed). Targets blocked by robots.txt or on another host are reported as info and not fetched |
canonical |
error / warning | rel=canonical points elsewhere, so the page's hreflang is ignored (error); a target declares a different canonical (warning) |
duplicate-language |
error / info | Same code pointing to different URLs in one set |
source-conflict |
error / warning | HTML, header and sitemap disagree (different URL for a code: error; different language sets: warning) |
html-lang |
warning | <html lang> missing or its language differs from the hreflang the cluster assigns to that page (en-CA vs en-GB is fine) |
relative-url |
warning | hreflang URL is not absolute |
head-placement |
warning | hreflang <link> inside <body> (search engines ignore it) |
no-annotations, redirect |
info | Page has no hreflang at all; the requested URL redirected |
python -m hreflang_check URL [URL ...] [--crawl] [--max-pages N] [--sitemap URL] [--no-sitemap]
[--format markdown|json] [--output FILE] [--fail-on error|warning]
[--delay SECONDS] [--timeout SECONDS] [-v]
- Single or several URLs: each page plus its hreflang targets is checked.
--crawl: also follows same-host links.--max-pagesdefaults to 20 and is capped at 30 for the whole run (pages fetched, targets included).- Sitemaps: found through the
Sitemap:lines of robots.txt, else/sitemap.xml, or pass--sitemap URL(up to 5 files are read). --delay: seconds between requests, never below 1. robots.txtCrawl-delayis honoured (up to 10 s).- Exit codes:
0no errors,1errors found (--fail-on warningalso fails on warnings),2usage error or an input URL that could not be fetched (network failure, blocked by robots.txt).
As a library:
from hreflang_check import check
report = check(["https://example.com/en/"], delay=1.0)
report.counts() # {'error': 0, 'warning': 1, 'info': 0}
report.findings[0] # Finding(severity, check, page, message, target)examples/sample-local-report.json shows the JSON format (from a local test site); examples/*.md are the reports below.
Run on 2026-09-29 against my own sites: a DeMark Studio local page that exists in English and French, and my portfolio (English + /es/). GET only, at least 1 s apart, 8 requests per run.
$ python -m hreflang_check https://demarkstudio.ca/en/web-design/miramichi https://marvin.demarkstudio.ca/
# hreflang-check report
- Pages checked: 4 on demarkstudio.ca, marvin.demarkstudio.ca
- HTTP requests made: 8 (including robots.txt and sitemaps)
- Result: 0 error(s), 2 warning(s), 0 info
- Sitemaps read: https://demarkstudio.ca/sitemap.xml, https://marvin.demarkstudio.ca/sitemap.xml
## Pages
| Page | HTTP | html lang | hreflang entries (html / header / sitemap) |
|---|---|---|---|
| https://demarkstudio.ca/en/web-design/miramichi | 200 | en | 3 / 0 / 3 |
| https://marvin.demarkstudio.ca/ | 200 | en | 3 / 0 / 2 |
| https://demarkstudio.ca/fr/conception-web/miramichi | 200 | fr | 3 / 0 / 3 |
| https://marvin.demarkstudio.ca/es/ | 200 | es | 3 / 0 / 2 |
## Findings
### https://marvin.demarkstudio.ca/
- **WARNING** `source-conflict`: HTML <link> and sitemap define different language sets (only in HTML <link>: x-default)
### https://marvin.demarkstudio.ca/es/
- **WARNING** `source-conflict`: HTML <link> and sitemap define different language sets (only in HTML <link>: x-default)
## Notes
- https://demarkstudio.ca: robots.txt read
- https://marvin.demarkstudio.ca: robots.txt read
The two warnings were real: the portfolio's HTML declared x-default, but its sitemap did not, so the two sources disagreed. I added the x-default alternate to the sitemap generator, redeployed, and ran the same command again:
# hreflang-check report
- Pages checked: 4 on demarkstudio.ca, marvin.demarkstudio.ca
- HTTP requests made: 8 (including robots.txt and sitemaps)
- Result: 0 error(s), 0 warning(s), 0 info
- Sitemaps read: https://demarkstudio.ca/sitemap.xml, https://marvin.demarkstudio.ca/sitemap.xml
## Pages
| Page | HTTP | html lang | hreflang entries (html / header / sitemap) |
|---|---|---|---|
| https://demarkstudio.ca/en/web-design/miramichi | 200 | en | 3 / 0 / 3 |
| https://marvin.demarkstudio.ca/ | 200 | en | 3 / 0 / 3 |
| https://demarkstudio.ca/fr/conception-web/miramichi | 200 | fr | 3 / 0 / 3 |
| https://marvin.demarkstudio.ca/es/ | 200 | es | 3 / 0 / 3 |
## Findings
No issues found.
## Notes
- https://demarkstudio.ca: robots.txt read
- https://marvin.demarkstudio.ca: robots.txt read
The DeMark pair was clean both times: en, fr and x-default in the HTML and the sitemap, reciprocal, self-referencing, <html lang> matching, canonical pointing to itself. (https://demarkstudio.ca/fr/ itself 301-redirects to /en/; the tool reports that as info when you pass it, since only the local pages have French versions.)
$ python -m pytest -q -p no:cacheprovider --import-mode=importlib hreflang-check
66 passed in 22.83s
- Only same-host targets are fetched. Alternates on another domain (
example.fr) are reported as info and their return links cannot be checked. - Return links are checked against pages that were fetched in the same run; targets of targets are not followed (use
--crawlfor that, within the 30-page cap). - The page is read as served to a plain HTTP client: hreflang injected by JavaScript is not seen, and no
Accept-Languageor cookies are sent. - Script subtags (
zh-Hans) are accepted; numeric regions (es-419) are flagged because hreflang wants ISO 3166-1 alpha-2. - Unlike search engines, a missing self-reference is only a warning, since return links can still make the set work.
--crawlis a small link follower for checking, not a general crawler: it skips images, scripts and other non-page files.
Marvin Palencia, founder of DeMark Studio, Miramichi, New Brunswick, Canada. Portfolio: marvin.demarkstudio.ca
MIT License.