Unreal string tables and the CSV round trip that actually works
Unreal ships with a more complete localization pipeline than most engines. There is a text type designed for translation, an asset type for holding translatable strings, a dashboard that gathers text out of your project and writes industry-standard translation files, and a compile step that produces per-culture data your packaged build loads at runtime. Almost nothing is missing.
The trouble is that all of it is opt-in, and the parts that are not opted into fail silently. Text stored the wrong way is not gathered. Text created by joining strings together is not gathered. A key that was copied along with a duplicated asset produces a conflict you only see if you read the gather log. In every one of those cases, the pipeline reports success and the string ships in English.
This article walks the loop as an ongoing workflow rather than a one-time setup: what has to be true about your text before anything else works, what a string table actually is and what its CSV looks like, how the round trip runs, what it misses, and what happens to your translations when the English changes.
FText is the only text that can be localized
Unreal has two text types that beginners use interchangeably and the localization system does not. FString is a mutable string, appropriate for file paths, identifiers, network payloads, and anything the player never reads. FText is a display string: it carries a namespace and a key, it can be looked up in translation data at runtime, and it is what the gather step collects.
The rule that follows is simple and needs to be enforced from the start: anything a player reads is FText, and nothing else is. A widget bound to an FString is not localizable, will never appear in an export, and will never trigger a warning. The most common way this happens is not ignorance — it is convenience. Someone needs a number in a sentence, reaches for string concatenation because it is one line, and the sentence leaves the localization system permanently.
There is also a specific escape hatch worth knowing because it is easy to use by accident. Constructing an FText directly from a runtime string produces text that is deliberately culture-invariant: it is a valid FText, it compiles, it displays, and it is never translated. That behaviour is correct for a player's typed name or a raw score value. It is a bug for anything else, and it looks identical in code to text that does get localized.
In C++, literal display text goes through the localization macros so that a namespace and key are attached at compile time. In Blueprints, a text literal typed into a node is gathered from the asset, but only when your gather configuration includes packages — which is the default, and also the thing people disable while trying to speed up a gather.
What a string table is, and the CSV behind it
A string table is a named collection of key-to-source-string entries that any FText can reference instead of carrying its own literal. Instead of the same warning message being typed into four different widgets as four independent entries, all four point at one table row. The value shows up immediately: the string is written once, translated once, and changing it changes every place it appears.
The format is deliberately plain. A string table can be created as an asset in the editor, or backed by a CSV file with a key column, a source string column, and an optional comment column that carries context for the translator. That CSV is a normal file in your repository — reviewable in a pull request, diffable, and editable by someone who does not have the editor open.
The comment column is worth using properly rather than leaving empty. It is the only structured place in this pipeline where you can tell a translator that a two-word string is a button and not a heading, that a name belongs to a character rather than a place, or that the surrounding UI gives thirty characters of room. That information reaches the translation file, which means it reaches the person doing the work.
The other reason to prefer string tables over loose literals is key readability. Text entered directly into a Blueprint or an asset gets an automatically generated key, and automatically generated keys look like machine identifiers in the exported file. A translator opening a file full of them has no idea what any entry is, and neither does the person reviewing the translation six months later. Table keys are ones you chose.
Key,SourceString,Comment UI_NEW_GAME,New game,Main menu button. Fits about 16 characters. UI_CONTINUE,Continue,Main menu button. Disabled with no save data. MSG_LOW_HEALTH,Health critical,HUD warning. Shown over gameplay in red. NPC_SMITH_NAME,Halvor,Character name. Male blacksmith. Do not translate.
The round trip: gather, export, translate, import, compile
The localization dashboard organises work into targets, and each target runs the same five stages. Understanding what each one produces is what lets you debug the pipeline when something does not appear in the game.
Gather walks the sources you configured — C++ source files, packages and assets, and asset metadata — and collects every piece of localizable text into a manifest. The manifest is the authoritative answer to the question 'what does this project consider translatable?' If a string is missing here, nothing downstream can save it, and you should stop and fix the gather rather than working around it.
Export writes translation files, one per culture, in the standard gettext PO format. This is what you send out. It is the correct handoff artifact because translators and translation software already understand it, and because it round-trips comments and context rather than losing them.
Translation happens outside Unreal. Import reads the returned files back into the per-culture archives, which are the stored record of what each string translates to. Compile then turns those archives into the binary per-culture data the running game actually reads.
Two things about compile catch people. It is a separate step, so freshly imported translations do not appear in the game until you run it — a large share of 'my translation is not showing up' reports are exactly this. And the compiled data has to be included in your package: there is a packaging setting listing which localizations ship, and a culture missing from that list works perfectly in the editor and is absent from the build you send to a store.
# Launch a packaged build in a specific culture to check it MyGame.exe -culture=ja # Useful during testing: the editor and the packaged build # resolve fonts and layout differently, so check both.
The text the gather never sees
Every project has some. Finding it is a deliberate task, not something the pipeline reports, and the categories repeat across projects:
- Sentences assembled at runtime from separate pieces. The fix is a format string with named arguments as one FText entry, so the translator receives a whole sentence and can reorder the parts for their language
- Player-facing text stored as FString in a data table, data asset, or config file, where nothing about the type marks it as display text
- Text inside plugins and third-party content, which lives in its own localization target and is not covered by your game target
- Enum display names, asset metadata, and editor-facing labels that turned out to be player-facing after all
- Strings baked into textures and materials — signage, logos, labelled tutorial images — which are localization work with no text file anywhere
- Platform-facing text that never lived in your project at all: store descriptions, achievement names and descriptions, and rich presence strings, all entered in a publisher portal
Namespaces, keys, and what happens when the English changes
An FText entry is identified by its namespace and key together. That pair is what the manifest records, what the archive stores a translation against, and what the runtime looks up. Everything about the durability of your translations follows from how stable that pair is.
Two consequences matter in practice. First, changing a key throws away the translation attached to the old one — the entry is simply gone as far as the archive is concerned, and the new key arrives untranslated. Second, and less obvious, the source string is recorded alongside the translation. When you edit the English text without changing the key, the pipeline treats the entry as needing new work rather than silently keeping a translation that was written for different words. That behaviour is correct, and it also means every casual wording tweak in English generates paid translation work in every language you ship. Batch English copy edits before a translation round rather than trickling them out afterwards.
The conflict case is worth knowing because it produces a warning people learn to ignore. Duplicating a Blueprint or a widget can duplicate the text keys inside it, so two different source strings end up claiming the same namespace and key. The gather reports this as a conflict, and it is a real problem: only one of them can win the lookup, so somewhere in the game a string will display the wrong translated text with no other symptom. Read the gather log after every gather, and treat conflicts as errors rather than noise.
Verifying a culture before you call it shipped
The editor is a friendly environment and a packaged build is not. Fonts resolve differently, missing localization data falls back differently, and text that fits a preview window at editor resolution does not necessarily fit at the resolutions your players use. Verification means launching the packaged build in the target culture and playing it.
The highest-value passes are the ones that cover states rather than screens. Menus get tested because they are the first thing anyone opens. What ships broken is the mid-combat warning, the network disconnect dialog, the tooltip that only appears on a full inventory, and the end-of-run summary — all places where text appears briefly, in a constrained box, under conditions a casual pass never reaches.
Also check the things that live outside your project files, because nobody owns them by default. Achievement names and descriptions, store page text, and any platform-side strings are entered separately from the game build, are frequently forgotten in the second and third languages, and are visible to every player who opens the store page before they ever launch the game.