Translations and hreflang
How the locale axis is decided, why the sitemap matters more than the links, what reciprocity means, and how to declare a gap you meant to leave.
This is the check goflag was built for, and the one where it was wrong first. See the failure that started it.
The matrix
goflag builds a route × locale matrix. A route is a URL with its locale
segment removed: /fr/pricing and /en/pricing are one route, /pricing, in two
locales. A cell is filled when a page for that route and locale was found or
declared: a page the crawl fetched, a URL your sitemap lists, or a
<link rel="alternate" hreflang> a crawled page points at. An empty cell is a
hole: a translation the site serves in one language and not in another.
That third source is taken on trust: a <head> advertising a translation the
site does not serve fills the cell anyway, so the gap reads as translated
instead of missing. goflag counts those cells —
diagnostics.unverifiedAlternates, the ones neither crawled nor listed in your
sitemap — and changes no verdict on them. Refusing to believe an unlisted
alternate would invent holes on every site that uses
sitemap: false
deliberately, which is trading a miss for a false alarm. Read that number next to missingTranslations: a 0 beside a
non-zero count is the one place this report can be quietly wrong.
A route is a pathname, unless you declare a cluster. goflag pairs
translations by pathname, so on its own it only recognises them when the slug is
the same in every locale — a site that translates its slugs, /en/pricing and
/fr/tarifs, would produce two routes, each filled in one locale, and two holes
on a pair that is fully translated.
Declaring the cluster fixes that, and goflag reads two declarations:
- Your sitemap. When a
<url>entry carriesxhtml:link rel="alternate" hreflang— the form Google documents — every member of that cluster shares one row whatever its path. This is the source that survives--coverage structural: a<url>entry names its whole cluster whether or not any member was sampled. - Your pages'
<head>. Two crawled pages whosehreflangalternates point at each other are one page in two languages. A one-sided declaration forms nothing — the peer has to confirm it — and both pages have to have been crawled, so on a large sampled site this source is often silent where the sitemap one is not. The two are complementary; where they disagree, the sitemap wins and the disagreement is reported indiagnostics.warnings.
Either way the row is named after the x-default target, so it does not move
when a locale joins, and the count is reported under
diagnostics.declaredClusters. A cluster declaration decides identity only: it
never fills a cell and never asserts that a page exists. An hreflang alternate
in a page's <head> does both jobs — it pairs, and it fills a cell, as above.
A cluster with no x-default is not merged — the anchor has to be something the
site states about itself, or the row would be renamed every time a locale is
added, and every baseline would go red on a site where nothing changed. For the
same reason a <head> cluster whose x-default points somewhere outside the
cluster is not merged either: pointing every page's x-default at your home page
is a common mistake, and merging on it would collapse your whole site into one
route. Those refusals are counted under declaredClusters.refused.
hreflang.cluster-incomplete and
hreflang.sitemap-mismatch read the same
declarations, so a declared cluster produces neither a hole nor a complaint: both
group the sitemap's entries and the crawled pages by cluster before comparing
them, which is what stops /en/pricing and /fr/tarifs from looking like two
half-covered routes.
Without any declaration there is nothing to pair on, and everything falls back to
the pathname. On a slug-translating site that declares neither way, the hole
count is not meaningful and neither is that rule — it reports the <head> and
the sitemap as disagreeing on exactly the pages that are correct. The trustworthy
findings there are the reciprocity codes below and
hreflang.missing — read the rest knowing the
pairing is wrong, and declare your clusters when you can.
None of this touches a site with no hreflang at all. There are no alternates to
pair on, so no cluster is formed, and hreflang.missing reports exactly what it
always did. Cluster pairing can only ever move a row; it never fills a cell, so
it cannot hide a hole.
Coverage decides what the matrix can see. When a sitemap is found, goflag
defaults to --coverage structural, which samples three URLs
per template family. The matrix is built from that selection, so a single missing
translation inside a sampled family is likely never to be looked at, and
0 missing translations then means "none in the sample". Run --coverage all
when the translation matrix is the thing you care about.
Where the locale axis comes from
The axis is the list of locales the site is considered to serve. Everything else depends on it, so it is decided explicitly, in this order:
-
--locales: your declaration of intent, and the only way to make a locale the site does not serve yet show up as missing. Its locales are unioned with whatever the sitemap shows, never substituted for them: a locale your sitemap demonstrably serves stays on the axis even when you leave it out of the flag. The report labels the sourceexplicitas soon as the flag is present, even where part of the axis came from the sitemap.goflag https://example.com --locales "en,fr,es,pt-br"Pass it on any locale-prefixed site, sitemap or not: it is also what folds
/en/…and/fr/…into one route family for structural coverage. Without it, a site with many top-level sections under/encan have those sections sampled as though they were one family. -
The sitemap. Locale prefixes read from the leading path segment of each
<loc>, including sitemap indexes. This is the reliable automatic source for path-prefixed sites, because a sitemap lists pages whether or not anything links to them. Path prefixes are all it reads: if your locales live on subdomains or ccTLDs, in a query parameter, or are declared with<xhtml:link rel="alternate" hreflang>inside the sitemap, goflag sees no locales, the axis has fewer than two entries, and every axis-dependent check is silently off. Pass--localeson those sites — nothing warns you. -
Nothing. If there is no sitemap and no
--locales, goflag does not guess. It reports the prefixes it saw during the crawl, with the evidence for each, and turns off every check that depends on the axis: translation holes,hreflang.missing,hreflang.cluster-incompleteandhreflang.sitemap-mismatch. The reciprocity checks below do not depend on the axis and keep running, so a run with no axis can still reportmissingTranslations.reciprocityentries and still come out yellow.
That third case used to be a guess, and the guess was wrong in a way that mattered. See below.
Reciprocity
A filled matrix is not enough: the pages have to point at each other. These are
reported under missingTranslations.reciprocity, with a code rather than a rule
id, because they are computed cross-page rather than from the per-page registry:
| Code | What it means |
|---|---|
missing-back-link | A declares an alternate to B, and B does not link back. hreflang is only honoured when reciprocal; a one-way declaration is discarded. Only checked when goflag actually fetched B and its final URL matches the declared href exactly — an alternate outside the crawl is not checked at all. |
x-default-missing | A page advertises two or more locales and no hreflang="x-default", which is where a visitor matching none of your languages is sent. Reported per page, so a four-locale cluster that never declares it produces four findings. |
locale.invalid | The tag is malformed (pt_BR, with an underscore), or it names a language, script or region that does not exist (qq, pt-ZZ). Existence is answered by the ICU data in your Node, not by a table, so zh-Hant, sr-Latn-RS and es-419 are accepted and x-default always is. On a small-icu Node the check falls back to shape alone and says so in diagnostics.warnings. |
A rejected tag voids the alternate for the reciprocity walk. It is not removed from the matrix: the tag still becomes a column of its own, so every route that does not declare it reports a hole in a locale that does not exist. Fix the tag rather than filtering the finding.
Declaring a gap you meant to leave
Not every hole is a defect. A legal page that exists only in French, a blog post never translated: pass the route and it stops being reported.
goflag https://example.com --ignore-holes "/legal/**" --ignore-holes "/blog/2019-*"The glob matches the locale-free route, not the URL: /legal/** covers
/fr/legal/privacy and /en/legal/privacy in one line. It is repeatable, and the
suppressed count is still reported under diagnostics.ignoredHoles, so a
suppression cannot hide how much it is hiding.
The hreflang rules, and the question
Beyond the matrix, three checks read the locale axis. Two of them render a verdict; the third deliberately does not.
hreflang.missing— error. The site serves several locales and a page declares no alternates at all. Google ignores a cluster whose pages do not all point at each other, so a page with none is not weakly grouped, it is ungrouped: four translations compete as four pages.hreflang.cluster-incomplete— warning. Your sitemap publishes a locale your<head>never names. Listing a URL is you saying that version exists and should be indexed, so leaving it out of the cluster puts it outside the group it belongs to.hreflang.sitemap-mismatch— a question, not a finding. The other direction: your<head>advertises a translation the sitemap does not list. Nothing requires an hreflang-declared page to be in a sitemap — Google calls the two declaration methods equivalent — so a page correctly cross-linked and deliberately kept out of it is doing nothing wrong. goflag hands you both lists and asks which of your two generators is right. It appears under--advisories, never in the counts, and never fails a build.
These were two rules until 2026-08-15, when the pair was split: one half is backed by Google's reciprocity requirement and one half is backed by nothing, and a single rule could not honestly carry both.
The bug that started this
The first version of goflag reported zero missing translations on a four-locale
site that carried not a single hreflang tag. The locale axis was derived from
the alternates the pages declared, so a silent site had one column and every
check passed.
goflag was detecting the absence of hreflang by reading the hreflang.
Capturing the first baselines surfaced the same class of mistake twice more. The
worst read the path segment /cv as Chuvash (a real ISO 639-1 code) and
invented 31 translation holes that never existed.
The first is fixed outright, and it is why the axis is now either declared by you, taken from your sitemap, or not assumed at all.
The second is contained rather than eliminated. With no declared axis goflag no
longer invents one, but the matrix itself still keys on path shape, so on a site
that does have a declared axis a page at /cv still becomes a locale:
/cv is read as route / in locale cv, and every other route then reports a
hole in a language the site does not serve.
--ignore-holes cannot suppress those. It globs the route, and the phantom
gaps land on every route except the one the /cv page occupies — there is no
route named /cv to match. --exclude does not help either when your sitemap
lists the page, because sitemap URLs are seeded into the crawl without passing
through the glob filters.
So until goflag can be told that a segment is not a locale, the recourse is to
read those findings knowing the column is spurious: each one names cv as the
missing locale, and the pages under it declare <html lang="fr"> rather than
cv — the cross-check the candidate report already uses to tell a CV page from
a Chuvash edition.