Unreal Engine localization: FText, the dashboard loop, and the traps

In Unreal Engine, a string is translatable only if it reached the engine as FText with a namespace and a key. Everything else in the pipeline follows from that one decision. Once your text is FText, localization is a loop you run per culture: gather the text out of code and assets, export a PO file per culture, translate it, import it back, compile it to the binary form the runtime loads, and stage those cultures into the package.

Editor panel names, console commands and API signatures move between engine versions, so take those from the official documentation's localization chapters rather than from any article, including this one. What does not move is the design work: which strings are FText, how keys are named, which cultures you ship, and how a sentence is assembled. Two parts of that you can verify on your own machine before Unreal is involved at all, and this article names the tools that check them.

Only FText is gathered: FText, FString and FName

FText is display text that carries its own identity: a namespace, a key within that namespace, and the source text. In C++ the LOCTEXT macro declares one using the file's LOCTEXT_NAMESPACE, and NSLOCTEXT takes the namespace as an argument instead. Text pins on Blueprint nodes and text properties on assets are FText as well, so text authored in the editor is gathered from packages once those paths are configured.

FString is a mutable character buffer with no identity attached, so there is nothing for the gather step to record. FName is a cheap, case-insensitive identifier used for lookups such as asset names, tags and row names, and it should never hold something a player reads. The trap that catches almost every first project is FText::FromString: it compiles, it displays, and at runtime it really is an FText, but it was built from a bare string and has no namespace or key, so the gather step has nothing to write down. Anything assembled that way ships in your source language in every culture. Outside of debug output and developer-only screens, treat it as a code-review finding.

Namespace plus key is also what lets a translation survive an edit. Two unrelated screens can both use the key Start as long as their namespaces differ, and the engine keeps their translations apart. Within one namespace, a key must mean exactly one thing forever: reuse it for a second meaning and you have quietly asked for one translation to cover two sentences. If your project still has player-facing literals scattered through gameplay code, the groundwork in externalizing hardcoded strings comes before any of the dashboard work.

  • FText — everything a player reads, in code and in assets
  • FString — internal buffers, file paths, network payloads, log lines
  • FName — identifiers and lookup keys, never displayed
  • FText::FromString — a runtime conversion, not a localization source
  • Namespace plus key — the identity that carries a translation across edits

The dashboard loop and what each step writes

The Localization Dashboard drives a fixed sequence, and each step produces a specific artifact. Gather scans the locations you configured and writes the source entries into a manifest, with translations kept per culture in an archive. Export writes a PO file per culture for translators. Import reads the returned PO back into the archive. Compile turns the archive into the binary localization resource (a LocRes file) that the runtime loads for the active culture. The exact file names and the settings for each step belong to the engine version you are on, so confirm them in the official documentation's Localization Dashboard chapter.

Two consequences matter for planning. First, gather only looks where you told it to look, and a path you forgot to configure produces no error at all, just silence: the string never appears in the PO and nobody notices until a translator asks about a screen. Record how many entries each gather produced and compare that number run to run, because a sudden drop is the only visible symptom. Second, compiled localization data is a build artifact. Editing a PO changes nothing in a running build, and a culture that is not staged into the package is missing from the shipped game even though it worked in the editor.

There is a second authoring route for the same pipeline. A String Table asset holds rows of key and source text, can be authored from CSV, and is referenced from widgets and data assets. The text is still FText with a namespace and key, so it flows through the same gather, export and compile steps. Pick it when writers or designers own the text and want to edit it outside the editor; keep LOCTEXT in code when a string belongs next to the logic that shows it. The Unreal String Table CSV workflow covers that route in detail.

  • Gather — scan configured code and asset paths, write the manifest and archives
  • Export — one PO file per culture, handed to translators
  • Import — merge the returned PO back into the archive
  • Compile — build the per-culture resource the runtime loads
  • Package — stage the cultures you actually ship, then test the packaged build

Validate the PO before it reaches the engine

A PO file is plain gettext, described in the gettext PO file format, so you can check its structure with standard tools without opening the editor. The shape below covers both an Unreal export and the PO a gettext-based translation tool hands back: msgctxt carries the namespace and the key, msgid is the source text, and msgstr is the translation. Unreal's own export writes its comment labels tab-separated and adds a source-location comment, and it writes no flag comments at all. Unreal writes the namespace and key into a single context string, and the exact spelling of that context is affected by the project's PO format setting, so confirm it in the official documentation's chapter on the dashboard's import and export options before writing tooling against it.

  • Two entries with the same context and the same source text are a fatal error: running msgfmt with the check option on such a file reports a duplicate message definition and exits non-zero
  • The same key with different source text is not an error: you get two separate entries, so a reused key is a bug only your own review will catch
  • Deleting a placeholder from a translation also passes: removing the damage argument from the translated damage line still reports three translated messages and exits zero, because Unreal-style named arguments are not a format gettext understands
  • The fuzzy marker is added by gettext-based translation tools and never by Unreal, whose PO format writes neither flag comments nor previous-source comments, so treat it as the tool's signal that a translator needs to look again
  • Running the check on every returned PO catches broken files before they reach an import, but placeholder parity is a check you have to add yourself
msgid ""
msgstr ""
"Project-Id-Version: MyGame\n"
"PO-Revision-Date: 2026-09-12 10:00+0900\n"
"Last-Translator: \n"
"Language-Team: ja\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#. Key: MainMenu_Start
#: /Game/UI/WBP_MainMenu.WBP_MainMenu
msgctxt "MainMenu,MainMenu_Start"
msgid "Start"
msgstr "はじめる"

#. Key: Tutorial_Start
msgctxt "Tutorial,Tutorial_Start"
msgid "Start"
msgstr "開始"

#. Key: Hud_Damage
msgctxt "Hud,Hud_Damage"
msgid "{Attacker} dealt {Damage} damage"
msgstr "{Attacker}が{Damage}のダメージをあたえた"

#. Key: Hud_Pickup
#, fuzzy
msgctxt "Hud,Hud_Pickup"
msgid "You picked up {Count} coins"
msgstr "コインを{Count}枚ひろった"

Culture names and the fallback chain

Unreal identifies a culture with an IETF language tag, the same tags described in BCP 47 language tags. Capitalization is conventional rather than arbitrary, and the canonical forms are easy to confirm: language subtags are lowercase, script subtags are title case, and region subtags are uppercase. Running Intl.getCanonicalLocales in Node on a handful of inputs returns zh-hans-cn as zh-Hans-CN, pt-br as pt-BR, fr-ca as fr-CA, PT as lowercase pt, while ja and es-419 are already canonical. Use those spellings for culture folders and settings so the tags you configure match the tags the runtime resolves.

Resolution walks from the most specific tag to the least: a request for zh-Hans-CN falls back to zh-Hans, then to zh. That is why you should ship zh-Hans and zh-Hant rather than a bare zh. A plain zh leaves the script unstated, and the standard likely-subtag data resolves it to Simplified for mainland China, which is a guess your Traditional Chinese players will notice. Node confirms the same data: maximizing zh gives zh-Hans-CN, and minimizing zh-Hans-CN gives back zh. For Portuguese and Spanish the split is regional rather than by script, and the practical differences are covered in Brazilian Portuguese notes.

zh-hans-cn  ->  zh-Hans-CN      maximize("zh")          ->  zh-Hans-CN
pt-br       ->  pt-BR           minimize("zh-Hans-CN")  ->  zh
fr-ca       ->  fr-CA           maximize("pt-BR")       ->  pt-Latn-BR
PT          ->  pt              maximize("ja")          ->  ja-Jpan-JP
ja          ->  ja              fallback: zh-Hans-CN -> zh-Hans -> zh

Formatting, plurals, and the FString trap

Any sentence assembled from parts has to be one FText with named arguments, formatted through FText::Format with an argument map. Named arguments matter because a translator can move them anywhere in the sentence: Japanese puts the particle after the noun, German pushes the verb to the end, and neither is possible if the order is decided in C++. The failure mode is FString concatenation. Gluing a noun between two fixed fragments freezes English word order, and because the fragments are strings rather than FText, they are not gathered either, so the sentence never even reaches a translator.

Counts need more than substitution. Checking plural categories with Intl.PluralRules in Node shows how uneven this is: Japanese has one category, English has two, Russian and Polish have four, and Arabic has six. A sentence that switches on a number therefore needs a form per category in those languages, which a single string with a number pasted into it cannot express. Unreal's own formatting supports plural and gender forms inside the text itself, with a syntax close to but not identical to the industry-standard message format described in the ICU MessageFormat guide. Because that syntax has changed across versions, take the exact spelling from the official documentation's text formatting chapter. The design point is version-independent: the branch belongs inside the translatable string, not in an if statement around two separate strings.

good:  "{Attacker} dealt {Damage} damage"   one FText, arguments reorderable
bad:   "You found " + ItemName + "!"       word order frozen, never gathered
plural categories: ja 1, en 2, ru 4, pl 4, ar 6

Where a first Unreal localization stalls

Most of the time lost on a first pass goes to a short list of problems that are invisible until someone looks at the right build. Work through them deliberately rather than waiting for a bug report.

  • Gather paths left out of the configuration: the missing strings produce no warning, so compare the gathered entry count against the previous run
  • Cultures not staged in the packaging settings: the language works in the editor and is absent from the packaged build
  • Localized assets are a separate job from text: voice lines and textures with baked-in words follow a per-culture asset convention, and that work needs its own schedule
  • Fonts render only the glyphs they contain, so a default Latin face shows empty boxes for Japanese or Chinese until a composite font with a fallback face covers them, as described in why tofu boxes appear and how to fix them
  • Editing a source string makes its existing translation stale rather than missing: the archive stores the source text each translation was made from, and the compile step has a setting for whether stale translations still ship, so a late change after a text freeze quietly reopens work. An alternative PO format offered in the settings does not carry that stored source, which disables the check entirely
  • Switching culture at runtime leaves behind any screen that stored the result of converting an FText to a string, because that copy is a plain string and no longer tracks the active culture
  • Translated strings run longer than English in most languages, so check the real translations in the real UI rather than trusting the editor preview
  • The loop completing only means text moved through the pipeline, so finish with a pass over the running game of the kind described in localization QA and LQA, on a packaged build rather than in the editor
before calling one culture done, on a packaged build:
  every returned PO passes a structural check, no duplicate contexts
  placeholder names match the source exactly, reordering allowed
  the gathered entry count matches the previous run
  the culture is selectable and every screen is translated

Related articles