A Godot translation workflow that survives the fifth update
Most Godot projects get their first translation working in an afternoon. You add a CSV, let the importer turn it into translation resources, wrap a few strings in tr(), and the menu switches languages. The feature works, and it is genuinely one of the easier parts of the engine.
What breaks is everything that happens afterwards. The second time a designer rewrites a line of dialogue. The first time a translator sends the file back with their own column ordering. The day someone plays in French and finds that a third of the item names were never in the table at all. None of those are failures of the engine — they are failures of the workflow wrapped around it.
This article is about that workflow: how to choose between CSV and gettext, how to keep a single file authoritative, what Godot translates for you automatically and what it quietly does not, how to handle the assets that are not text, and how to push out an update without discarding the translations you already paid for.
CSV or gettext PO: decide by who edits the file
Godot can load translations from a CSV table and from gettext PO files, and the engine does not care which you pick. The decision should be made on one question: who opens the file to change the text?
A CSV holds every language in one grid — one row per key, one column per locale. That shape is excellent when you and a small number of collaborators edit the text directly. Coverage is visible at a glance because an empty cell is a missing translation, and a diff of the file reads as a list of changed rows. Its weaknesses are the things a grid cannot express well: per-string notes for a translator, text containing line breaks, and plural forms that vary by language.
A PO file holds one language and carries structure a CSV cannot. It has room for translator comments, source references, a context field that lets the same source string be translated two different ways, plural rules per language, and a flag marking an entry as needing review after the source changed. Nearly every piece of professional translation software opens PO files natively, so if you are handing work to someone who translates for a living, this is the format they will ask for. The cost is that you can no longer see all languages side by side, and you now manage one file per locale.
A reasonable default: start on CSV while you are the only person touching the text, and move to PO at the point where you are sending files out to translators or your language list starts needing plural handling. Godot can generate a POT template from your project, so the move is not a rewrite. Check the localization section of your project settings for the POT generation options in your version.
key,en,ja,fr ui.menu.new_game,New game,はじめから,Nouvelle partie ui.menu.continue,Continue,つづきから,Continuer dlg.inn.greet,"Welcome, traveller.","ようこそ、旅の方。","Bienvenue, voyageur."
The table is the source, and its column headers are the locale list
When Godot imports a translation CSV it produces a translation resource per locale column. Those generated resources are derived artifacts, in exactly the same sense as a compiled shader or an imported texture. The file a human edits is the CSV. Keep that distinction visible in your repository so nobody ever fixes a typo in the wrong place.
Split the table by domain rather than keeping one enormous file: interface strings, dialogue, item and skill names, credits. Godot happily loads several translation files at once, and the split pays for itself in two ways. Diffs stay small and readable, and you can hand a translator only the file they need instead of the whole game. The one constraint to respect is that key lookup is global, so keys must stay unique across every file — a domain prefix in the key does that for free.
One trap is specific to the CSV importer and catches people who think of the file as a spreadsheet: every column after the key column becomes a locale. Adding a helpful status or notes column produces a translation set for a language called status. Keep working metadata out of the imported file — in a separate sheet, a separate file, or in the PO comments if you have moved to gettext.
That also means the column headers are not decoration. Each one is a locale name matched against the locale you set at runtime, and a header the engine does not recognise produces no error — it produces a language nobody can select, and testers who report that switching to it does nothing. Use the locale spelling Godot itself uses rather than the one your spreadsheet or your translator used, and print the list of loaded locales once at startup in debug builds. TranslationServer exposes both the current locale and the set that actually loaded, so comparing that against the languages you believe you shipped is one line of code and catches typos immediately.
While you are there, decide explicitly what an untranslated string should look like. With no fallback configured, a missing entry renders the key itself: ugly in front of a player, and extremely useful during development because gaps become impossible to miss. With a fallback locale configured, gaps quietly show your source language instead — safer to ship, much harder to notice. Keeping raw keys in development builds and the fallback in release builds gives you both behaviours where each one helps.
What Godot translates for you, and what it silently skips
Control nodes translate their own text automatically when the locale changes, which is why a Label whose text is set to a key in the scene editor simply works. That convenience shapes two habits worth adopting deliberately.
The first is order of operations. Any string assembled from parts must be translated as a template and filled in afterwards, never assembled and then passed to tr(). A sentence built in code has never existed in your table, so looking it up finds nothing and it passes through unchanged — in your source language, on every locale, forever. This failure is invisible in testing if you happen to be testing in the source language.
The second is knowing when to switch automatic translation off. A Label displaying a player-entered name, a save file title, or a chat message is running arbitrary player text through the key lookup. Almost always nothing happens, because a missing key passes through untouched, which is exactly why the one time it collides with a real key nobody catches it. Nodes that display user or runtime data should have automatic translation disabled; the property name for this has changed between Godot versions, so confirm it in the documentation for the version you are on.
Then there is the text that never reaches the system at all. Strings loaded from your own data files, names defined in a resource or JSON that the importer never sees, text set in a tool script, strings inside an addon, and English left as literal placeholder text in a scene that nobody remembered to key. These do not error and do not warn. The only way to find them is to look at the game in a language you have fully translated and notice what is still in the source language.
# Wrong: the sentence is built first, so this key never existed
label.text = tr("You found %s!" % item_name)
# Right: translate the template, then substitute
label.text = tr("msg.found_item").format({"item": item_display_name})Remaps: the assets that also have a language
Text in a table is only part of what changes between languages. A title logo with the game name drawn into it, a signpost texture, a tutorial image with labelled buttons, a recorded voice line, a font that covers a script your default font does not — all of these are language-dependent assets, and none of them are strings.
Godot handles this with localization remaps: a mapping from one resource path to a per-locale replacement, applied when the resource is loaded. Set it up in the localization section of your project settings, and code that loads the original path gets the right variant at runtime without any conditional logic.
Two operational cautions. Remaps live in project settings rather than next to your translation files, so they are the thing people forget when adding a language six months later — keep a list of every remapped asset in the same folder as the translation table, so updating one list obviously means updating the other. And a remap entry that points at a resource which does not exist for some locale becomes a load failure in that locale only, which your normal testing will not reach.
The cheaper option is to need fewer remaps. Text drawn over an image at runtime instead of baked into it costs one Label and removes an entire category of per-language asset work. That is a decision made when the art is authored, not when the translation arrives, which is why it is worth raising early.
Updating without losing work
The expensive part of localization is not the first pass. It is the fifth update, when the table has a thousand rows, three languages are complete, one is half done, and a writer just revised forty lines of dialogue.
Keep row order stable. If your export and your translator's editor disagree about ordering, every round trip produces a diff where nothing is readable and real changes hide among reshuffled rows. Pick an order — sorted by key is the least controversial — and enforce it on both sides.
Handle changed source text explicitly, because this is the failure mode with no visible symptom. When the English changes but the key does not, every existing translation stays in place and every cell still looks filled, so the file reports full coverage while shipping text that no longer matches. Something has to record which source revision each translation was made against: a companion file with the source text as it was at translation time, a marker on the row, or the review flag if you have moved to PO. Whatever you choose, the requirement is the same — a changed source must produce a visible signal somewhere.
Be conservative about deleting. An orphan row that no code references costs a few bytes and confuses nobody. A key deleted while something still requests it renders raw key text to a player. Search the project before removing a row, and when in doubt leave it.
Before you call a language done
A filled column is a claim about the table, not about the game. The check that matters is running the build in that language and looking at it. A short pass covering these points finds most of what a spreadsheet cannot show you:
- Every screen a player can reach, including confirmation dialogs, error messages, and the pause menu — these are usually keyed last and tested least
- Text that only appears in a particular state: a full inventory, a death screen, a network error, an achievement unlocking
- Strings assembled from variables, which are the ones most likely to have been built before translation rather than after
- Glyph coverage — missing characters render as blank boxes rather than errors, so a font that does not cover the script fails silently
- Layout, since translated strings are rarely the same length as the source and a fixed-width button is the usual casualty
- The list of loaded locales, confirming that the language you think you shipped is the one the engine actually registered