You call TranslationServer.set_locale("es"). Some labels switch. Others keep the old language forever — no error, no warning, nothing in the debugger. The cause is almost never the one people go looking for: every node in the tree was told the locale changed. Paste a GDScript or C# file below and this runs the LocGuard frozen-translation scanner on it, in your browser. Below the tool is what each case does at runtime, measured against Godot 4.7 — including the three causes that look guilty and are not.
Paste one script — the one that builds the UI that will not update. Nothing is uploaded; the scan runs in this page.
label.text = tr("MENU_START") # frozen at whatever locale was active right here
label.text = "MENU_START" # follows the locale, forever
Godot re-translates the string a node is holding, every time the locale changes. So the node has to be holding the key. The moment you store the result of tr() in text, you have thrown the key away, and there is nothing left to re-translate. Both lines look identical on screen until someone changes language. The first one is the bug.
Different symptom — the raw key (MENU_START) on screen from the very first frame, in every language? That is a different failure with different causes: Godot 4 shows the raw key instead of the translation — the complete checklist.
Each claim carries an id like S3 that maps to an assertion in verify_locale_switch.gd, run against Godot 4.7.stable.official.5b4e0cb0f:
docs/verify_locale_switch.sh /path/to/Godot_v4.7-stable_linux.x86_64 # ### pass 1 — default project (fallback locale = en) # RESULT: 13 passed, 0 failed # ### pass 2 — fallback locale cleared # RESULT: 13 passed, 0 failed
The probe builds its tables in memory, so you can drop that one .gd file into your own project and run it against your engine build.
Run this on a node that is not updating, right after a locale change:
print(JSON.stringify(label.text)) # 1. is the node still holding the key? print(JSON.stringify(label.atr(label.text))) # 2. what will it draw? print(TranslationServer.get_locale()) # 3. which locale is actually active?
| What you see | What it means |
|---|---|
step 1 prints the translation ("Comenzar"), not the key | the frozen assignment — this page, and the scanner above finds it |
| step 1 prints the key, step 2 prints the key too | the key is missing from the new locale's table → the raw-key checklist |
| step 1 prints the key, step 2 prints the right text, screen still wrong | auto_translate_mode is off on that node or a parent (S7) |
step 3 prints something other than what you passed to set_locale() | S9 — locale standardization |
| step 3 prints your locale but nothing is translated | S10 — no table for it, falling back silently |
Quoting with JSON.stringify in step 1 is not decoration: on a Spanish build "Comenzar" and "Comenzar" are the same pixels whether the node is holding a key or a value. The quotes are how you tell which.
Two Labels, built the two ways, side by side. Locale is es when both are built; then it changes to en.
keyed.text = "MENU_START" | frozen.text = tr("MENU_START") | |
|---|---|---|
stored in text | "MENU_START" | "Comenzar" |
drawn while es | Comenzar | Comenzar |
drawn after set_locale("en") | Start | Comenzar |
S2b/S3).print(JSON.stringify(node.text)) — if it prints a sentence instead of a key, it is frozen.label.text = "MENU_START". Godot translates on draw, every time, for free.This is the part that sends people down the wrong path for a day. Both nodes in the table above received NOTIFICATION_TRANSLATION_CHANGED when the locale changed — the frozen one included. Godot did its job; the node re-translated the string it was holding, and the string it was holding was already Spanish.
So if you are about to write this:
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSLATION_CHANGED:
label.text = tr("MENU_START") # re-freezing it on every change
…you are building a hand-cranked version of something the engine already does, and you now have to remember every string forever. It works, and it is the wrong amount of work. It is only the right answer when the node is deliberately out of the auto-translation path (S7).
This is the failure that makes the bug report unreadable, because the player does not see the old language. They see a third string that was never the translation of anything they asked for.
The node holds "Start" (frozen while the game was in English). The locale changes to Spanish. Godot looks "Start" up in the Spanish table — and if your table happens to contain a key called Start, it finds it:
frozen.text == "Start" # frozen English value frozen.atr(frozen.text) == "ARRANQUE" # the es translation of the KEY "Start"
Measured as S5. Collisions like this are not exotic once a table has a few hundred short UI strings in it — Start, Back, Close, Level, Score are all plausible as both a key and an English value. The symptom is a string that is in the right language and completely wrong.
score_label.text = tr("SCORE_FMT") % 7 # -> "Puntos: 7"
text now holds "Puntos: 7". There is no key Puntos: 7 and there never will be one, because the 7 came from the game. Switching back to Spanish does not fix this one either — unlike S3, where the original locale at least renders correctly by accident.
Formatted strings are the case where you do have to re-run the format on locale change. Keep the key and the arguments, and rebuild:
var score := 0
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSLATION_CHANGED:
_refresh()
func _refresh() -> void:
score_label.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED # S7
score_label.text = tr("SCORE_FMT") % score
The auto_translate_mode line is what stops S5 from happening to the result.
OptionButton, ItemList and TabBar items split the same wayItems added from code are stored strings like any other, and they are auto-translated on draw:
opt.add_item("OPT_ONE") # follows the locale -> "One"
opt.add_item(tr("OPT_ONE")) # frozen -> "Uno"
Measured as S8. Same rule, same fix: add the key. Items typed into the editor (the items array in the .tscn) are stored as keys already, so they are the correct form by default — which is exactly why "it works in the scene I built by hand and breaks in the one I populate from code" is such a common shape for this bug.
Three things that look guilty and are not. Each is measured, so you can stop suspecting them.
auto_translate_mode turns off atr(), not tr()off.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
off.atr("MENU_START") == "MENU_START" # auto-translation is off
off.tr("MENU_START") == "Comenzar" # explicit translation still works
So a node with auto-translation disabled shows raw keys, in every language, from the first frame — a different symptom from the one this page is about. If you disabled it to stop S5, you have taken on re-translating that node yourself.
TranslationServer.standardize_locale("pt-BR") returns "pt_BR", and after set_locale("pt-BR"), get_locale() returns "pt_BR" (S9a/S9b). Godot normalizes it for you, so the hyphen form is safe to pass in — but a string comparison against what you passed in (if TranslationServer.get_locale() == "pt-BR") is false, and any dictionary you key by the raw locale string will miss.
set_locale("ja") on a project with no Japanese table succeeds. get_locale() returns "ja". Nothing is logged. What the player sees depends on internationalization/locale/fallback:
| fallback setting | tr("MENU_START") returns |
|---|---|
en (Godot's default) | Start — the English text, silently |
| cleared | MENU_START — the raw key |
Both measured as S10, which is why verify_locale_switch.sh runs twice. The default is the dangerous one: a language button that appears to do nothing at all is usually this, not a broken table.
A Label that was never added to the tree still translates its key correctly (S11). "I built it before adding it as a child" does not explain a stale string.
It reads one line at a time, and one line of source does not say which node text belongs to. So:
S7 case is undecidable here. A node you took out of the auto-translation path on purpose and re-translate by hand is supposed to be written this way. When a file mentions AUTO_TRANSLATE_MODE_DISABLED or NOTIFICATION_TRANSLATION_CHANGED, the scanner says so under the finding and leaves the judgement to you.var s := tr("K") on one line and label.text = s on the next is the same bug and is not reported..tscn in the editor are already keys (S8), so there is nothing there to find.Findings are a place to look, not a verdict. The verdict is print(JSON.stringify(node.text)) at runtime.
The tool at the top of this page runs frozen-scan-core.js — the same file, byte for byte, that the command-line scanner runs. Point the CLI at a directory and it walks every .gd and .cs instead of the one file you pasted. Zero dependencies, writes nothing, exits non-zero on findings, so it works as a pre-commit hook or a build gate:
node docs/find_frozen_translations.js /path/to/your/godot/project node docs/find_frozen_translations.js /path/to/project --json # claim id per line
All 13 claims on this page, the .gd probe that asserts them, and the shell script that runs it twice against your own engine build. docs/locale-switch-does-not-update-ui.md
The linter over your translation table: missing keys, placeholder drift, unbalanced BBCode, empty translations, orphan keys, overflow budgets. Extraction covers what Godot's own POT generator misses — OptionButton/ItemList items and TabBar titles in scenes, Tr() in C#. github.com/leobaray/locguard
Everything above without leaving Godot: scan from a dock, double-click a finding to open the file at the line, ready-made CI presets. LocGuard Pro on itch.io
LocGuard is a linter over your translation table — it can prove a key is missing, that a placeholder drifted between locales, that a cell is empty, that BBCode is unbalanced. Every one of those is decidable from the CSV and the keys your project uses.
The defect on this page is not visible in the table at all. label.text = tr("MENU_START") uses a key that exists, in a locale that exists, with no placeholder drift — a perfectly clean table, and the string still freezes. It is a property of the source, which is why it ships as the separate scanner above rather than as a rule that would answer from the wrong evidence.
Six tiles in, a wired 47-bitmask TileSet out. The other half-hour of Godot busywork nobody should be doing by hand. Blobsmith on itch.io · free Lite version · what it does
Measured on Godot 4.7.stable.official.5b4e0cb0f, 2026-08-15. Re-run the claims yourself with docs/verify_locale_switch.sh <godot-binary>; if a future engine build changes an answer, the script says which id and how.