Quick answer: Add your .po or .csv translation file to Project Settings > Localization > Translations, confirm the locale string in the file matches exactly what TranslationServer.get_locale() returns (e.g. en not en_US), and make sure your translation keys are byte-for-byte identical to the strings you pass to tr().

You call tr("MAIN_MENU_PLAY") and it comes back as "MAIN_MENU_PLAY". Or you switch the locale and every label still shows the default language. Godot’s localization system is well-designed but has three silent failure modes that all produce the same symptom: the original key, untranslated, staring back at you. Here’s how to debug each one.

The Symptom

The issue always presents the same way: tr("KEY") or TranslationServer.translate("KEY") returns "KEY" unchanged. Additional signs that help narrow down the cause:

What Causes This

The TranslationServer looks up translations in a very specific way. When you call tr("KEY"):

  1. It checks the current locale via TranslationServer.get_locale().
  2. It searches through all loaded Translation objects for one that matches that locale.
  3. Inside the matching Translation, it does a case-sensitive, exact-match lookup of "KEY".
  4. If any step fails, it returns the original key string.

The three failure modes map to steps 2, 2 (again), and 3 respectively: file not loaded, locale string mismatch, key string mismatch.

The Fix: Register the Translation File in Project Settings

No translation file is loaded automatically. You must explicitly register each translation file in Project Settings > Localization > Translations > Translations (the first sub-tab). Click the folder icon and add your .po or .csv file. You can also do this via GDScript if you load translations dynamically:

# Load and add a translation at runtime
var translation: Translation = load("res://locale/fr.po")
TranslationServer.add_translation(translation)

# Verify which translations are currently loaded
for locale in TranslationServer.get_loaded_locales():
    print("Loaded locale: ", locale)

For .csv files, the first column is the key and subsequent columns are translations, with the first row specifying locale codes as column headers. Godot automatically imports .csv files in res:// as translation resources if the first cell is keys:

# translations.csv (first row must use 'keys' as the ID column header)
# keys,en,fr,de
# MAIN_MENU_PLAY,Play,Jouer,Spielen
# MAIN_MENU_QUIT,Quit,Quitter,Beenden
# SETTINGS_VOLUME,Volume,Volume,Lautstärke

The Fix: Locale String Format

Godot uses IETF BCP 47 locale codes. The most common mistake is using en_US (underscore, with region) when the translation file declares en (language only), or vice versa. TranslationServer first tries an exact match, then falls back to a language-only match — but only if a fallback is configured.

# Check the actual current locale at runtime
print(TranslationServer.get_locale())   # e.g. "en", "fr", "de", "zh_CN"
print(OS.get_locale())                  # system locale (may differ)
print(OS.get_locale_language())         # two-letter language code only

# Set locale explicitly (call before tr() lookups)
TranslationServer.set_locale("fr")

# Set a fallback locale for missing translations
# Project Settings > Localization > Locale > Fallback
# (default is "en") — ensure this matches a loaded locale exactly

Always print TranslationServer.get_locale() and compare it to what your .po file declares in its Language: header. A mismatch here — even just en vs en_US — means zero translations will be found for that language.

The Fix: Key Exactness

Translation key lookup is case-sensitive and whitespace-sensitive. tr("PLAY_BUTTON") will not find a key stored as "Play_Button" or "PLAY_BUTTON " (trailing space). Validate your keys programmatically if you have many of them:

# Debug helper: check if a specific key is found in the active translation
func check_key(key: String) -> void:
    var result = tr(key)
    if result == key:
        push_warning("Translation missing for key: '" + key + "' in locale: " + TranslationServer.get_locale())
    else:
        print("OK: '", key, "' -> '", result, "'")

# tr() vs TranslationServer.translate() — both call the same lookup,
# but translate() accepts an explicit locale argument:
var french_text = TranslationServer.translate("PLAY_BUTTON", "fr")
print(french_text)   # "Jouer" if fr.po is loaded and key exists

Switching Locale at Runtime

Calling TranslationServer.set_locale() updates the active locale, but it does not automatically refresh the text of nodes already on screen. Labels, Buttons, and RichTextLabels that have their text set via tr() must be re-set after a locale change. A common pattern is to use the TranslationServer signal or a dedicated localization manager autoload:

# Simple runtime language switch
func set_language(locale: String) -> void:
    TranslationServer.set_locale(locale)
    # Re-apply translations to all active UI nodes
    _refresh_ui(get_tree().root)

func _refresh_ui(node: Node) -> void:
    if node is Label and node.has_meta("tr_key"):
        node.text = tr(node.get_meta("tr_key"))
    for child in node.get_children():
        _refresh_ui(child)

# Alternatively: Godot 4 built-in auto-translation
# Set Label.text to the translation key and enable:
# Label > Auto Translate > Enabled = true
# Godot will call tr() on the text automatically when locale changes.

Godot 4.1+ introduced Auto Translate on Control nodes. When enabled, the node automatically calls tr() on its text property whenever the locale changes, removing the need for a manual refresh loop. Enable it per-node in the Inspector under Node > Auto Translate.

POT Generation and the .po Workflow

Godot can generate a .pot (Portable Object Template) file from your project automatically via Project Settings > Localization > POT Generation. Add your script files and scenes as sources, click Generate, and Godot extracts all tr() calls into a .pot file. Translators then create .po files from the template using tools like Poedit. This workflow prevents key drift — the POT always reflects exactly what keys your code uses:

# Example .po file structure (French translation)
# msgid is the key passed to tr()
# msgstr is the translated string

# "Language: fr\n"
# "Content-Type: text/plain; charset=UTF-8\n"

# msgid "MAIN_MENU_PLAY"
# msgstr "Jouer"

# msgid "SETTINGS_VOLUME"
# msgstr "Volume"

# msgid "DIALOG_CONFIRM_QUIT"
# msgstr "Voulez-vous vraiment quitter ?"

Related Issues

See also: Fix: Custom Fonts and Assets Not Loading in Godot Export.

See also: Fix: Godot GDExtension Not Loading in Exported Build.

When tr() returns the key, the answer is always in the project settings, the locale string, or a stray capital letter.