Fixing “No translation found for …” (@angular/localize ID drift)

@angular/localize fails the production build with No translation found for "8930164603609365017" whenever a source message’s auto-generated ID changes but the translated XLIFF still carries the old one — almost always after an innocent copy edit to the source text. The ID is a hash of the message content, so editing the English string, its meaning, or its description silently mints a brand-new ID; your messages.de.xlf still maps the previous hash, the two fall out of sync, and the AOT compiler errors out (or, with i18nMissingTranslation: warning, ships the page with the raw source text instead). This page explains why the hash drifts, how custom @@id markers stop it, and how to re-sync source and target XLIFF so the build goes green again.

The failure is confusing because nothing in the template looks wrong — the i18n attribute is still there, the German translation still exists in the file. What broke is the invisible link between them: the hash that joins source unit to target unit no longer matches.

@angular/localize ID drift after a copy edit Editing the source message changes its content hash, producing a new auto-generated ID. The translated XLIFF still references the old hash, so the new ID has no matching target and the build reports No translation found. A custom @@id pins the ID so edits no longer drift it. Auto-generated ID = hash(source text + meaning + description) Source v1 "Sign in" id 8930…5017 de.xlf target id 8930…5017 "Anmelden" ✓ matches Source v2 (copy edit) "Log in" id 4471…8820 (new!) de.xlf target still id 8930…5017 orphaned ✗ no match Fix: custom @@id i18n="@@auth.signIn" ID is now stable across copy edits source text can change freely; target stays linked
Auto-generated IDs are content hashes, so a copy edit orphans the old target; a custom @@id pins the link so the source text can change without breaking the translation.

Root cause: the ID is a hash, not a name

@angular/localize follows the message-extraction model where every translatable unit needs a stable identifier joining the source message to its translations. When you write <h1 i18n>Sign in</h1> and don’t supply your own ID, the extractor computes one for you. As documented in the Angular Localization Module Setup, that auto-generated ID is a deterministic hash derived from three inputs:

  1. The source message text (including any ICU expressions and interpolation placeholders).
  2. The optional meaning — the text before the | in i18n="meaning|description".
  3. The optional description — the text after the |.

Change any of the three and you get a different hash. This is by design: the hash is the message’s content fingerprint, so two units with identical text and meaning collapse to one ID (good for deduplication), and a reworded unit is treated as a genuinely new message that needs re-translation. The problem is that a copy edit you consider cosmetic — Sign inLog in, fixing a typo, tightening a sentence — is, to the hasher, a new message. The extractor emits a new <trans-unit id="..."> in messages.xlf, but your already-translated messages.de.xlf still contains the old ID. At AOT compile time the compiler looks up the new ID, finds nothing in the target file, and reports:

Error: No translation found for "4471809494976448820" ("Log in").

The same root cause produces the inverse symptom too: an orphaned target unit (old ID, real translation) that nothing references anymore, which silently rots in the file and inflates your “translated” count while the live string is actually untranslated.

Two ID formats exist in the wild, controlled by the i18nLegacyMessageIdFormat/format flags and the Angular version: the long decimal hash shown above (current default) and older legacy MD5-style hex IDs. Mixing toolchains that disagree on the format produces the exact same “No translation found” error even when no copy edit happened — the IDs were computed by two different algorithms.

Minimal reproducible example

Start from a working, fully translated pair. The template:

<!-- app.component.html -->
<h1 i18n>Sign in</h1>

Extracted source (messages.xlf) and its German target (messages.de.xlf) agree on the hash:

<!-- messages.de.xlf -->
<trans-unit id="8930164603609365017" datatype="html">
  <source>Sign in</source>
  <target>Anmelden</target>
</trans-unit>

ng build --localize succeeds. Now make one “harmless” copy edit:

- <h1 i18n>Sign in</h1>
+ <h1 i18n>Log in</h1>

Re-run the localized build without re-extracting and re-translating. The source hash is now 4471809494976448820, but messages.de.xlf still only contains 8930164603609365017:

✖ Error: No translation found for "4471809494976448820" ("Log in").

The German file was never touched, the i18n attribute is intact, and the translation literally still exists in the file — yet the build fails, because the join key changed underneath it.

The fix

There are two layers: stop the drift from happening again (custom @@id), and re-sync the files that already drifted.

Layer 1 — pin a stable custom ID

Give every meaningful message an explicit ID with the @@ prefix. Once pinned, the ID is decoupled from the source text, so copy edits no longer change it:

<!-- The text after @@ is the stable ID; it survives any source-copy edit -->
<h1 i18n="@@auth.signIn">Log in</h1>

<!-- Full form: meaning|description@@id — meaning/description still aid translators,
     but no longer feed the ID, so tweaking the description is also safe now -->
<button i18n="User action|Primary auth CTA@@auth.signIn.cta">Continue</button>

In TypeScript, the tagged-template form takes the same marker via a leading metadata block:

// `:@@id:` pins the ID; everything before the final colon is i18n metadata
const label = $localize`:@@auth.signIn:Log in`;

With @@auth.signIn fixed, editing Log inSign inSign in to continue never changes the ID. The translator’s <target> stays attached; the source in the XLIFF just goes stale (which a re-extract harmlessly refreshes) instead of the whole unit orphaning.

Layer 2 — re-extract and reconcile the drifted target

For messages that already drifted (or for legacy strings you haven’t pinned yet), re-extract the source and merge it into each locale, preserving existing translations. Plain re-extraction overwrites messages.xlf, so use a merge tool that maps old IDs to new ones rather than discarding them:

# 1. Re-extract the current source messages
ng extract-i18n --output-path src/locale --format xlf2

# 2. Merge new source into each target, keeping existing <target>s.
#    xliffmerge / @ngx-i18nsupport detects added & removed units and
#    carries unchanged translations across the ID change where text matches.
npx xliffmerge --profile xliffmerge.json en de fr

For a one-off rename where you know the new ID, the surgical fix is to rename the ID in the target file so the existing translation re-attaches — no re-translation needed:

  <trans-unit id="8930164603609365017" datatype="html">
-   <source>Sign in</source>
+   <source>Log in</source>
    <target>Anmelden</target>
  </trans-unit>
- <trans-unit id="8930164603609365017" datatype="html">
+ <trans-unit id="4471809494976448820" datatype="html">

Cross-format pipelines (PO ↔ XLIFF) make this reconciliation trickier because the ID semantics differ between formats; if your translations round-trip through gettext, handle the mapping as described in PO / XLIFF format bridging so IDs survive the conversion.

The i18nMissingTranslation escape hatch — and why it’s only an escape hatch

The build option i18nMissingTranslation decides what happens when a unit has no target. Setting it to warning (or ignore) downgrades the hard error so the build completes:

// angular.json — build options for the de configuration
{
  "configurations": {
    "de": {
      "localize": ["de"],
      // error (default) | warning | ignore — warning lets the build pass
      // but renders the raw SOURCE text for any unmatched ID
      "i18nMissingTranslation": "warning"
    }
  }
}

This is correct for intentionally partial locales (a freshly added language still being translated), but it is the wrong fix for drift: it papers over the orphaned ID by shipping English to German users. Use warning only when missing translations are expected and tracked; keep it at error on mature locales so drift fails loudly in CI instead of leaking source text to production.

Verification

Prove the fix two ways. First, the build itself: with the ID re-synced (or pinned), the localized AOT build must pass with i18nMissingTranslation back at its strict default:

# Must exit 0 with NO "No translation found" lines for a fully translated locale
ng build --configuration=production,de 2>&1 | tee build.log
grep -q "No translation found" build.log && { echo "ID drift still present"; exit 1; } \
  || echo "All IDs resolve — source and target XLIFF in sync"

Second, guard against future drift in CI by asserting that every source ID has a matching target in each locale file. This catches the orphan before someone flips i18nMissingTranslation to hide it:

# Every id in the source xlf must exist in de.xlf; lists the drifted ones
comm -23 \
  <(grep -oE 'id="[^"]+"' src/locale/messages.xlf | sort -u) \
  <(grep -oE 'id="[^"]+"' src/locale/messages.de.xlf | sort -u) \
  | sed 's/^/MISSING IN de.xlf: /'

An empty diff means source and target are fully reconciled. Wiring this grep into your translation gates keeps a copy edit from quietly orphaning a unit between releases.

When to escalate

The hash-drift fix assumes the mismatch is purely about IDs — same message, different join key. If the build still reports No translation found after re-syncing IDs, the cause is usually elsewhere: an ICU plural/select whose categories changed (the placeholders are part of the hashed source, so editing inside the ICU body drifts the ID too), a legacy vs current ID-format mismatch between two toolchains, or a unit that genuinely was never translated and needs to go back to translators. When the same drift keeps recurring across the codebase, the durable fix is a project-wide convention of mandatory custom @@id markers plus the CI reconciliation gate above — set that policy at the Angular Localization Module Setup level rather than patching files one error at a time. Translation-tool round-tripping through other formats is its own class of problem; route it through PO / XLIFF format bridging.

FAQ

Should I always use custom @@id instead of auto-generated IDs?

For any message that will be edited over its lifetime — which is most UI copy — yes. Auto-generated hashes are convenient for one-off extraction but make every copy edit a re-translation event because the ID moves with the text. A stable @@id namespace (e.g. auth.signIn, checkout.total) decouples wording from identity, so editors can refine English freely while translations stay attached. Reserve auto IDs for throwaway or never-edited strings.

Why does fixing a typo in the English text break the German build?

Because the auto-generated ID is a hash of the source text. A typo fix changes the text, which changes the hash, which mints a new ID with no matching <target> in messages.de.xlf — so the compiler reports No translation found. The German translation didn’t disappear; its join key no longer matches. Pin the message with a custom @@id and the typo fix stops touching the ID.

What does i18nMissingTranslation: warning actually do?

It downgrades the missing-target error to a build warning and renders the source text in place of the missing translation. That’s appropriate for a locale you’re still translating, but for drift it just hides the orphaned ID and ships English to non-English users. Keep it at the default error on completed locales so drift fails the build instead of silently degrading the page.

Part of Angular Localization Module Setup.