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:
- All keys return untranslated — usually means the file was never added to the project.
- Some keys work, others don’t — usually a key spelling or case mismatch.
- All keys work in one language but not another — usually a locale string format mismatch.
- Translations work at startup but switching language at runtime doesn’t update displayed text — text nodes need to be refreshed manually.
What Causes This
The TranslationServer looks up translations in a very specific way. When you call tr("KEY"):
- It checks the current locale via
TranslationServer.get_locale(). - It searches through all loaded
Translationobjects for one that matches that locale. - Inside the matching
Translation, it does a case-sensitive, exact-match lookup of"KEY". - 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.pofile declares in itsLanguage:header. A mismatch here — even justenvsen_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.