Safely renaming a translation key

You rename checkout.cta to checkout.submit, push, and the next morning the German, French and Japanese translations for that string are gone. The catalogue is not corrupt and nothing errored — the synchronisation job did exactly what it was told. It saw one key disappear and an unrelated key appear, archived the translations of the first, and queued the second as new work.

A rename is one of the few catalogue operations with no undo, because the loss happens in a system you do not control. This page covers why, and the procedure that avoids it.

How a naive rename reaches a translator A commit that renames a key looks to the extractor like one key removed and a different key added. The translation system records the old key as archived, taking its translations with it, and presents the new key to translators as untranslated work even though the source text never changed. What a rename looks like to each tool Your commit Extractor TMS Translator key a → key b a removed, b added b appears untranslated a archived with its translations
Nothing in this chain can tell that the two keys are the same string — unless you say so.

Root cause: identity is the key, and only the key

A translation memory system stores translations against an identifier. When the identifier changes, there is nothing left connecting the old translations to the new string — the source text may be byte-identical, but nothing in the format says “these are the same string under a new name.”

Some systems mitigate this with fuzzy matching: if the new key’s source text matches an archived unit exactly, the translation may be offered as a suggestion. That is a mitigation, not a guarantee. It depends on the archive being searched, on the match threshold, and on nobody having edited the source text in the same commit. Relying on it means relying on a heuristic to protect work that a translator was paid for.

The failure is worse when the rename and the locale files diverge across commits. If the English catalogue is renamed in one commit and the other locales in the next, there is a window in which the source has checkout.submit and every target still has checkout.cta. A sync running inside that window sees a key with no translations in any locale — which is exactly the state that triggers archival.

Which rename preserves translations and how Renaming a leaf key preserves translations provided every locale is renamed in the same commit. Renaming a whole namespace preserves them if the files move together and the old namespace name is aliased for a release. A rename that also changes the meaning must not preserve translations, because the existing ones answer the old question. Three renames, three procedures Translations survive? Procedure Leaf key only yes, if carried together rename in every locale, one commit Whole namespace yes, with an alias move files, alias the old name Meaning changed too no — and they must not new key, deprecate the old
The third row is the one to be honest about: sometimes losing the translations is the correct outcome.

Minimal reproducible example

# The rename that loses translations: source only, then push.
sed -i 's/"checkout.cta"/"checkout.submit"/' locales/en.json
git commit -am 'rename checkout cta key' && git push

# The nightly sync now reports:
#   + checkout.submit   (new, 0/4 locales)
#   - checkout.cta      (removed, translations archived)

Nothing in that output is wrong. The pipeline has no way to know the two lines describe one string.

The fix: move every locale in one commit, and leave an alias

Five steps that make a rename survivable Renaming the key in every locale file in a single commit keeps the catalogues consistent. Updating call sites in the same commit avoids a broken intermediate state. An alias resolving the old key to the new one protects reverts and cached clients. Pushing before the next synchronisation lets the translation system observe a coherent change. The alias is removed a release later and the removal recorded. The rename that keeps every locale 1 Rename in every locale file at once en, de, fr, ja — one commit 2 Update every call site in the same commit so no intermediate state is broken 3 Leave an alias for one release old key resolves to the new one 4 Push before the next TMS sync runs the sync sees a rename, not a delete 5 Remove the alias next cycle and record it in the deprecation log
Step one is the whole trick: if the locales move in separate commits, the sync sees a deletion.

The mechanical rename is a single script that touches every catalogue at once, so no intermediate state exists for a sync to misread.

#!/usr/bin/env bash
# rename-key.sh OLD NEW — renames a key across every locale in one commit.
set -euo pipefail
OLD="$1"; NEW="$2"

for f in locales/*/*.json; do
  # jq preserves ordering and fails loudly on malformed JSON
  jq --arg o "$OLD" --arg n "$NEW" '
    if has($o) then . + {($n): .[$o]} | del(.[$o]) else . end
  ' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
done

# Call sites move in the same commit, so no build is ever broken mid-rename.
grep -rl "$OLD" src | xargs sed -i "s/${OLD}/${NEW}/g"

git add locales src
git commit -m "rename ${OLD} -> ${NEW} across all locales"

The alias is the second half, and it is what protects you from the things you did not think of — a cached client bundle, a revert, a feature branch merged a week later that still references the old key.

// One release cycle of protection, then delete this entry.
const ALIASES: Record<string, string> = {
  'checkout.cta': 'checkout.submit',
};

export function resolveKey(key: string): string {
  const target = ALIASES[key];
  if (target && process.env.NODE_ENV !== 'production') {
    console.warn(`[i18n] "${key}" is renamed to "${target}"; update the call site`);
  }
  return target ?? key;
}

Verification

Two checks prove the rename landed cleanly: every locale has the new key, and no locale still has the old one.

# Every locale must carry the new key
for f in locales/*/*.json; do
  jq -e 'has("checkout.submit")' "$f" > /dev/null \
    || { echo "missing in $f"; exit 1; }
done

# No locale may still carry the old key
! grep -rq '"checkout.cta"' locales/ || { echo 'old key still present'; exit 1; }

# Expected
#   all 4 locales carry checkout.submit
#   old key fully removed

Add the first check to CI permanently, not just for this rename. Asserting that every locale file has the same key set catches the class of problem rather than the instance, and it is the same parity gate described in GitHub Actions i18n CI gates.

Renaming a whole namespace

Moving one key is a small operation; moving a namespace is the same operation applied several hundred times, and the failure mode scales with it. Two properties make it survivable.

The first is that leaf keys must not change. If checkout.submit becomes payments.submit, only the namespace segment moved and every tool that matches on the trailing path has a chance of reconciling it. If the leaf changes at the same time, nothing can.

The second is that the files move as files. A namespace is usually one file per locale, so the operation is a git mv of each file plus a single rewrite of the namespace prefix inside them. Doing it that way keeps version history attached to the content, which matters six months later when someone asks why a string is worded the way it is.

#!/usr/bin/env bash
# move-namespace.sh OLD NEW — one commit, every locale, history preserved.
set -euo pipefail
OLD="$1"; NEW="$2"

for dir in locales/*/; do
  [ -f "$dir$OLD.json" ] || continue
  git mv "$dir$OLD.json" "$dir$NEW.json"
done

# Call sites name the namespace explicitly; update them in the same commit.
grep -rl "useTranslation('$OLD')" src | xargs sed -i "s/useTranslation('$OLD')/useTranslation('$NEW')/g"
git commit -m "move namespace $OLD -> $NEW"

The namespace alias is a single entry rather than one per key, which makes it cheap to keep for a full release cycle:

const NS_ALIASES: Record<string, string> = { checkout: 'payments' };
export const resolveNamespace = (ns: string) => NS_ALIASES[ns] ?? ns;

One caveat specific to namespaces: they are usually also the loading unit, so renaming one changes which chunk a page requests. A client holding a cached bundle will ask for the old chunk name after the deploy, which is a 404 rather than a missing translation. Keeping the old chunk served as a redirect, or simply deploying the alias before the rename, covers the window.

When to escalate

If translations are already lost, the recovery path is the translation memory rather than the catalogue. Most systems keep archived units searchable for a period, and a pre-translation pass against the memory will restore exact matches automatically — the leverage mechanism described in translation memory and glossary management. Do this before translators start retranslating, because once a new unit is approved the old one stops being offered.

If your workflow renames keys frequently enough that this procedure is a burden, the keys are describing the wrong thing. Keys named after their content need renaming every time the copy changes; keys named after their role almost never need renaming at all. That is a catalog governance problem, and fixing it removes the operation rather than making it safer.

FAQ

Can I rename a key and change its wording in the same commit?

You can, but you should not want to. If the wording change alters the meaning, translations must not carry over, so the correct operation is a new key plus a deprecation rather than a rename. If the wording change is cosmetic — a typo fix — do it in a separate commit so that a fuzzy match still has a chance if the rename goes wrong.

Does this apply to source-text-as-key systems?

It applies harder. In Lingui or gettext the key is the source text, so every copy edit is a rename, and the mitigation is entirely fuzzy matching. Those systems compensate with strong matching support and an explicit review state for fuzzy units — but the trade-off is real, and it is the main argument for structured keys in a product whose copy changes often.

How long should an alias live?

One release cycle, and no longer. An alias that outlives its cycle becomes permanent infrastructure that nobody dares remove, and a catalogue with fifty aliases has two naming schemes rather than one. Record the removal date next to the alias when you add it.

Should renames be batched or done one at a time?

One at a time, each in its own commit. A batch of twenty renames in one commit is a single unreviewable diff across every locale file, and if the sync goes wrong there is no way to tell which rename caused it. Individual commits also mean a single revert undoes exactly one rename.

What if two people rename the same key in parallel branches?

The second merge produces a catalogue containing both new keys and neither old one, with translations attached to whichever landed first. Because the catalogue is generated, this shows up as a conflict in the locale files rather than in code, and it is worth resolving by regenerating from the source rather than by hand-merging JSON — hand-merged catalogues are where duplicate keys come from.

Part of String Catalog Governance.