You wrote tr("MENU_START"), you filled in the CSV, and the game shows MENU_START. Godot will not warn you: a failed lookup returns the key verbatim — no error, no push_warning, nothing in the debugger. Paste your translation CSV below and this runs the LocGuard linter on it, in your browser. Below the tool is the complete list of causes, each one measured against Godot 4.7 — including the two that no translation-table linter can see, so you stop looking for them here.
Paste the file you feed to Godot's CSV importer: first column is the keys, every other column is a locale. Nothing is uploaded — the check runs in this page.
Before reading anything below, run these three. They split the problem space in one pass, and the checker above only helps with one half of it.
print(TranslationServer.get_loaded_locales()) # 1. is anything loaded at all?
print(TranslationServer.get_locale()) # 2. which locale is actually active?
print(JSON.stringify(tr("MENU_START"))) # 3. what comes back, quoted?
[] — an empty array. Nothing is loaded; your keys are irrelevant. Go to A1.\n inside. Go to B — and paste your CSV into the checker above.Wrapping step 3 in JSON.stringify is not decoration: "MENU_START " and "MENU_START" are visually identical in the output panel and are different keys.
Each claim below carries an id like R2 or C4a that maps to an assertion in verify_translation_behavior.gd, run against Godot 4.7.stable. The script builds a throwaway project, writes a CSV seeded with every trap on this page (BOM, trailing space, quoted padding, comma in key, \n in key and in value, empty cell), imports it with the real engine and asserts each claim twice — once with the default fallback locale, once with it cleared:
docs/verify_translation_behavior.sh /path/to/Godot_v4.7-stable_linux.x86_64 # ### pass 1 — default project (fallback locale = en) # RESULT: 32 passed, 0 failed, 0 skipped # ### pass 2 — same tables, fallback locale cleared # RESULT: 32 passed, 0 failed, 0 skipped
Three claims are marked cited, not measured — they live in the editor, which has no headless entry point. They say so where they appear.
Importing a CSV produces the .translation files but does not add them to the project. Running godot --headless --path proj --import on a project with a valid loc.csv puts loc.en.translation and loc.es.translation on disk and leaves internationalization/locale/translations an empty array. At runtime get_loaded_locales() returns [] and every tr() returns its key (C1).
The files existing is not the same as the files being loaded. This is the one cause where the CSV, the keys and the code are all correct and nothing works.
.translation file. It writes internationalization/locale/translations=PackedStringArray(...) into project.godot — check that line into git and verify it after any merge.project.godot. Measured on a project with exactly this defect, the linter reports 0 findings and exits 0.With locale/fallback at its default "en", setting an unsupported locale does not give you raw keys — it silently gives you English (R7). With the fallback cleared, the same call returns the key (R7e).
--locales en,es,fr for exactly this, and then reports every key as an empty-translation warning for the missing one.Worth knowing so you stop suspecting it. Partial matches resolve: set_locale("es_MX") against an es-only table translates (R8); set_locale("pt") against a pt_BR-only table translates (R9).
What is brittle is case. standardize_locale() drops a country code that is the language's default ("pt-br" → "pt", "en_us" → "en"), keeps a non-default one ("es-MX" → "es_MX"), and leaves an uppercased language string untouched ("PT_BR" → "PT_BR") (R10a, R10b, R10c). If you build locale strings from a save file, a URL parameter or a filename, normalize the case before handing them to set_locale().
Lookup is an exact byte comparison. No trimming, no case folding, no normalization. This is the half the checker above is for.
tr("MENU_START ") returns "MENU_START " while MENU_START sits right there in the table (R2). A space at the end of a .tscn text = property or a CSV cell is invisible in every editor you will look at it in.
MENU_START, menu_start and Menu_Start are three keys (R3, R3b). Pick one convention — SCREAMING_SNAKE is the common one — and make it a review rule.
\n asymmetry behind itThis one is a genuine trap, because the CSV importer treats keys and values differently. Its default parameters are unescape_keys=false and unescape_translations=true, and both halves are measured:
LINE\nBREAK in the CSV imports as eleven characters, with a literal backslash and n (C4a).\n in a value imports as a real newline (C4c).tr("LINE\nBREAK") in GDScript — where \n is a newline — looks up a key that does not exist and returns itself (C4b, R4).You cannot fix this by writing the key more carefully in GDScript: the two representations can never meet while unescape_keys is off. Never put \n in a key. Keys are identifiers; put the line break in the translation, where it is unescaped for you.
" QUOTED_PAD " imports with its spaces intact; "QUOTED_PAD" does not find it (C2b). Quoting a CSV field protects the padding, it does not trim it. Only quote fields that need it — embedded commas, quotes, newlines. A comma inside a quoted key does survive the round trip correctly (C3).
If B1–B4 all look clean, stop reading the CSV and dump what actually got imported:
var t := load("res://loc.es.translation") as Translation
print(JSON.stringify(str(t.get_message("MENU_START")))) # "" means: not that key
An empty string means the key is not in that table under that exact spelling — regardless of what the spreadsheet shows you.
tr() and atr() are not the same call, and Godot's automatic translation of UI properties goes through atr(). Nothing in this section is a table defect, so the checker above will call your CSV clean while the screen keeps showing keys.
With auto_translate_mode = AUTO_TRANSLATE_MODE_DISABLED, tr("MENU_START") on that same node still returns "Comenzar" while atr("MENU_START") returns "MENU_START" (R11b). Your print(tr(...)) debugging says the translation works, and the screen keeps showing the key. Test with print(node.can_auto_translate(), node.atr(node.text)).
AUTO_TRANSLATE_MODE_INHERIT is the default, and it inherits the disabled state: a child under a disabled parent reports can_auto_translate() == false and returns raw keys from atr() (R12). One auto_translate_mode = 2 on a container silently un-translates the whole subtree under it. Walk up from the node printing can_auto_translate() until it flips.
label.text back tells you nothingA Control stores the raw key: label.text stays "MENU_START" and the translation happens on the way out (R13a). The outbound call resolves text and tooltip_text (R13b) and OptionButton item text (R13c). Test with atr(), not with text.
The fallback locale masks exactly the defects you are looking for.
R5). Clear the fallback and the raw key appears (R5e).R6); with the fallback off it returns the key (R6e).If your source language is en and the default fallback is en, an untested build can look complete in every locale while every missing string quietly serves English. That is not a bug — it is the intended safety net — but it means manual testing cannot find missing translations. Either check the table statically, which is what the tool at the top of this page does, or temporarily clear internationalization/locale/fallback and play through in a target locale.
| Cause | Claim | Caught by a table linter? |
|---|---|---|
| A1 files never registered | C1 | no — needs project.godot |
| A2 locale with no table | R7, R7e | only if the locale is declared (--locales) |
| B1 trailing/leading space | R2, C2a | yes — key-padding here; missing-key + orphan-key in the CLI |
| B2 case mismatch | R3, R3b | only with the project — needs to see the tr() call to compare against |
B3 newline / \n in key | R4, C4a-c | yes — key-escape-n / newline-key (error) |
| B4 padding inside quotes | C2b | yes — key-padding |
| C1 auto-translate off on node | R11b | no — scene property |
| C2 ancestor disabled it | R12 | no — scene property |
| D empty cell masked by fallback | R5, R5e | yes — empty-translation |
| D key only in fallback locale | R6, R6e | yes — empty-translation |
Two of the ten live in project.godot and in your scene tree, and no translation-table linter will ever see them. Knowing which half of the problem you are in is most of the debugging — which is why this page keeps saying so instead of selling you a tool that "checks everything".
One more thing the tool on this page cannot do that the CLI can: it never sees your code. missing-key (used in a tr() or a .tscn, absent from the table) and orphan-key (in the table, used nowhere) need both halves. That pair — same key, one used, one orphaned — is the signature of every cause in section B, and it is why a CSV-only check is a triage step, not a gate.
keys header cell, and locales and keys import correctly (C5). The fixture was written with a BOM on purpose. If your CSV is broken, the BOM is not why.R5).R7 vs R7e).The tool at the top of this page runs LocGuard's engine — the same file, byte for byte, that the command line runs. Point the CLI at a project instead of a table and it also reads your GDScript, C# and .tscn/.tres, which is what unlocks missing-key and orphan-key. It exits non-zero, so it works as a pre-commit hook or a build gate:
node src/cli.js my-game/ --source en --overflow es:1.6 --strict
The complete linter: 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#. Every rule is demonstrated to break in a real Godot 4.7 build before it ships. github.com/leobaray/locguard · the full checklist, with the verification script
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
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