Godot prints the key,
not the translation.

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.

    60-second triage

    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?

    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.

    Everything here is measured, not remembered

    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.

    A — nothing is loaded

    A1. The translation files were never registered

    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.

    A2. The active locale has no table, and nothing catches it

    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).

    A3. Locale matching is more generous than you think

    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().

    B — the table is loaded, but your key does not match

    Lookup is an exact byte comparison. No trimming, no case folding, no normalization. This is the half the checker above is for.

    B1. A trailing or leading space

    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.

    B2. Different case

    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.

    B3. A newline inside the key — and the \n asymmetry behind it

    This 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:

    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.

    B4. Padding inside quotes in the CSV

    " 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).

    B5. The key is in the CSV under a different byte sequence

    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.

    C — it translates in code, but not on screen

    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.

    C1. Auto-translation is off on the node

    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)).

    C2. An ancestor turned it off

    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.

    C3. Reading label.text back tells you nothing

    A 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.

    D — why your own testing missed it

    The fallback locale masks exactly the defects you are looking for.

    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.

    What the checker sees, and what it can't

    CauseClaimCaught by a table linter?
    A1 files never registeredC1no — needs project.godot
    A2 locale with no tableR7, R7eonly if the locale is declared (--locales)
    B1 trailing/leading spaceR2, C2ayes — key-padding here; missing-key + orphan-key in the CLI
    B2 case mismatchR3, R3bonly with the project — needs to see the tr() call to compare against
    B3 newline / \n in keyR4, C4a-cyes — key-escape-n / newline-key (error)
    B4 padding inside quotesC2byes — key-padding
    C1 auto-translate off on nodeR11bno — scene property
    C2 ancestor disabled itR12no — scene property
    D empty cell masked by fallbackR5, R5eyes — empty-translation
    D key only in fallback localeR6, R6eyes — 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.

    Debunked while measuring this

    Run the same checks in CI

    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

    LocGuard CLI — free, MIT, no dependencies

    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

    LocGuard Pro — the in-editor dock

    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

    Also from the studio

    Blobsmith — Godot 4 autotile generator

    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