Localization key naming conventions that survive growth

A localization key, also called a string key or a string ID, is the name your code uses to look up a piece of display text. The code holds the name, the translation files hold the words, and every language file answers to the same set of names.

The short answer to what those names should look like: a stable, lowercase, hierarchical ID that says where the string lives and what role it plays, never the words it currently contains. Below is the reasoning, a format rule small enough to hand to a linter, what four engines store as key identity, and the procedure for renaming a key without throwing translations away.

Keys are metadata that outlive the code that first wrote them. Translation memory, review history and every downstream tool are keyed on the key, so a choice that feels trivial at 200 strings is paid for by everyone at 20,000. Pick the rule while moving hardcoded text into a string table rather than after.

Three ways to identify a string, and what each costs

A hierarchical ID reads like a path: general to specific, separated by dots. Prefix search returns exactly one screen's strings, sorting groups related strings together, and a reviewer can tell where a string appears without opening the game. The cost is deciding the hierarchy early, and a string that later moves screens either keeps a key that lies about its location or forces a rename.

A flat ID drops the hierarchy, and it is what most small projects grow out of. Collisions arrive with the second screen that wants a key called title or confirm. The usual fix is a prefix convention, but settings_audio_master_volume is a hierarchy with no delimiter, so no tool can group by level and every grouping rule becomes a prefix guess.

The third way uses the source text itself as the key. This is what the gettext family does, where the msgid is the English sentence, and it costs nothing to set up because the key already exists in your code. It has four failure modes.

  • Edit the source text and the key changes with it, so every existing translation is orphaned, even when the edit was a typo fix and the meaning did not move.
  • One source string used in two places gets one translation. English tolerates that reuse in a way most target languages do not, so you need a second field to separate the uses, which in gettext is msgctxt.
  • A long sentence becomes a long key. A diff of the key list turns into a diff of prose, and line-based tools report rewrites as additions plus deletions.
  • The key says nothing about where the string appears, so location lives in a comment or a source reference that a spreadsheet round-trip can lose.
hierarchical   ui.settings.audio.master_volume
flat           settings_audio_master_volume
source text    msgid "Master Volume"
               msgctxt "settings/audio"

Three key bugs you can reproduce in a minute

The first is sorting. Sort the keys is an ambiguous instruction, and two tools that both claim to do it produce different files. A plain code-unit sort, the default for a JavaScript array sort, puts uppercase before lowercase and puts the underscore between the two cases, because the underscore is code point 95 and sits between Z at 90 and a at 97. Sorting audioMaster, audio_2 and audiob returns them in exactly that order. Since this rule bans uppercase from keys, in a real key list the underscore ends up before every letter, though still after the digits. A locale-aware comparison instead puts punctuation first and lowercase before uppercase, and its numeric option changes digit ordering again. All three orderings below are sorted, and they disagree.

The damage is not aesthetic. If your export tool sorts one way and an editor re-sorts another way on save, every commit shows hundreds of moved lines and the one real change hides among them. Pick an ordering, put it in the export script, and never let a general-purpose editor rewrite the file order.

The second is duplicate keys, and JSON hides them. Two entries with the same key parse to one entry and the last one silently wins. A file that sets ui.menu.continue to Continue and then to Resume parses to a single key whose value is Resume, with no warning, in both the JavaScript parser and Python's json module. A duplicate check therefore has to run on the raw text, not on the parsed object.

The third is keys that differ only by case. ui.Settings.audio.master_volume and ui.settings.audio.master_volume are two distinct keys in JSON and in most engines, so a lookup for one never finds the other, yet they collapse into one as soon as anything case-insensitive reads them: a spreadsheet lookup whose match ignores case, a database column with a case-insensitive collation, or an audio filename generated from the key. The cheap fix is to ban uppercase from keys entirely.

keys:  ui.Settings.audio  ui.settings.audio  ui.settings.audio2
       ui.settings.audio10  ui.settings.audio_2  ui.settings.audioMaster

code units:      Settings.audio  settings.audio  audio10  audio2
                 audioMaster  audio_2
locale-aware:    settings.audio  Settings.audio  audio_2  audio10
                 audio2  audioMaster
locale+numeric:  settings.audio  Settings.audio  audio_2  audio2
                 audio10  audioMaster

A naming rule you can write down and lint

Rules that live in someone's head are not rules. Write the format as a regular expression, put it in the check that runs with your tests, and the convention stops depending on who added the string.

  • Name the place and the role, never the words. ui.settings.audio.master_volume stays correct after the English is rewritten; continue_button_text stops being true the moment the button says Resume.
  • Order segments general to specific, and keep the vocabulary of the first segment small: the screens and systems you have, not a new word per feature.
  • Unless the final segment already makes the role obvious, as start, hint and greeting do, end the key with one role suffix from a closed list: title, body, button, tooltip, placeholder, error, label. This is the field that tells a translator whether they are writing four words or a sentence.
  • For counted text, use the plural support in your format if it has any, which is one key holding every variant. See how ICU MessageFormat handles plurals and select for the syntax. If the format has none, suffix variants with the CLDR category names and expect more than two: English needs one and other, Japanese needs only other, French needs three, Russian and Polish need four, Arabic needs six.
  • Give placeholders names rather than positions, keep the same name in every language so a mismatch is mechanically detectable, and make the name describe the data: player_name and item_count, not arg0 and arg1.
  • Ban four things outright: keys that are only digits, keys built from a fragment of the source text, keys carrying a date or ticket number, and status words like temp, new or final that describe the work rather than the string.
^[a-z][a-z0-9]*(_[a-z0-9]+)*(\.[a-z][a-z0-9]*(_[a-z0-9]+)*){1,4}$

PASS  ui.settings.audio.master_volume
PASS  shop.item.buy_confirm_body
PASS  tutorial.hint_1
FAIL  ui.Settings.Audio.MasterVolume     uppercase
FAIL  ui.settings.audio.master-volume    hyphen, not underscore
FAIL  ui.settings.audio.master volume    space
FAIL  0012                               digits only
FAIL  Are you sure you want to quit?     source text as key
FAIL  ui.settings.2ndPlayer              segment starts with a digit
FAIL  a.b.c.d.e.f                        6 segments, limit is 5
FAIL  ui..settings.audio                 empty segment

There is no standard, so here is a concrete reference point. The 25-key sample below covers a small game's menu, settings, shop, battle HUD, tutorial and one chapter of dialogue. Named with the rule above, it comes out at a median of 26 characters and a mean of 24, the longest key at 37. Depth runs from 2 to 4 segments, averaging 3.4, with 3 the most common.

Use that to sanity-check your own list, not as a target. If your median key is 60 characters you are repeating the parent in the child, as in ui.settings.settings_audio_settings_volume. If your typical depth is 6 or more, the hierarchy is carrying information that belongs in a separate table or in the file split.

Cap the number of top-level segments too. A first segment per feature grows without bound and the prefix search that justified the hierarchy stops narrowing anything. Ten to fifteen is plenty for a small game, and adding one should be a decision rather than a side effect of adding a screen.

ui.menu.start                          battle.hud.hp_label
ui.menu.continue                       battle.hud.mp_label
ui.menu.options                        battle.result.victory_title
ui.menu.quit                           battle.result.defeat_title
ui.settings.audio.master_volume        tutorial.movement.hint
ui.settings.audio.music_volume         tutorial.combat.hint
ui.settings.audio.sfx_volume           dialogue.ch01.innkeeper.greeting
ui.settings.audio.mute_button          dialogue.ch01.innkeeper.farewell
ui.settings.display.resolution_label   shop.title
ui.settings.display.fullscreen_toggle  shop.item.buy_button
ui.settings.language.title             shop.item.buy_confirm_title
ui.settings.language.hint              shop.item.buy_confirm_body
                                       shop.item.insufficient_funds

What Unity, Unreal, Godot and gettext store as identity

Before inventing a convention, find out what your engine treats as the identity of a string, because that decides whether a rename is a text edit or a data migration. Check the details in the chapter named below in each project's own documentation, since editor UI and field names move between versions.

Unity's Localization package stores a string table entry with both a key and a numeric Id, the mapping held in the shared table data for the collection. References resolve through the Id, so renaming a key in the editor keeps the existing translations attached. That protection covers references the editor saved; a key written as a string literal in code still breaks on rename. Confirm this in the String Tables chapter of the package documentation, and see the Unity CSV import and export workflow for how keys travel in and out.

Unreal identifies text by a namespace plus a key and records the source string alongside them, so the same key in two namespaces is two entries. Keys for text authored in assets are generated rather than typed, which means your convention applies to namespaces and to text you declare in code. The Localization chapter of the engine documentation has the export layout, and the Unreal string table CSV workflow covers the tables you do name yourself.

Godot 4's tr function takes the key directly, with an optional context argument separating two uses of the same key, and a CSV translation file uses the first column as the key. Identity is the string you type, so renames are a replacement across code and every translation file at once.

The gettext family makes the source text the identity: msgid is the source string, msgctxt disambiguates two uses of it, and msgid_plural carries the counted form. The structure of a PO file explains the fields. On gettext the naming decision moves from key names to msgctxt values, and the same rule applies there.

Renaming a key without losing the translations

A rename breaks the link between the old translation and the new key unless something explicitly carries the history forward, and most simple export and import pipelines do not. The visible result is that old translations vanish and every language falls back to the source text until it is retranslated, which is invisible in a spreadsheet review and obvious in the running game. When you have to rename anyway, do it as a migration with an artifact, not as a replacement someone ran once.

  • Freeze the key list for the release. No renames while a translation round is open, because the file coming back is keyed on names you have already changed.
  • Write the rename as a two-column mapping file, one row per key. That file is the deliverable and it goes in the repository, so the change is reviewable and repeatable.
  • Apply it with a script to the code and to every language file in the same run. A key renamed in code but not in the Japanese file falls back silently, and that is the failure you will not notice.
  • Keep the old key in the translation files for one release, holding the same text, so a reference you missed still resolves while you find it.
  • Report both directions afterwards: every key used in code that the mapping did not cover, and every key in a language file that no longer appears in code. The second list is what you delete, and only once the first is empty.
old_key,new_key
settings_audio_master,ui.settings.audio.master_volume
settings_audio_mute,ui.settings.audio.mute_button
buyConfirm,shop.item.buy_confirm_body

Three checks to run before the next translation round

Run the format regex over your current key list and read the failures, because that is where the convention already broke. Run a duplicate check on the raw text of every translation file, since the parser will not report one. Fix the sort order in the export script so the next diff shows only real changes.

Then write the rule where the next person adding a string will see it, with the closed list of role suffixes and top-level segments spelled out. The keys travel with the strings, so include the key list and the context those keys encode in the localization kit you hand to a translator. A key that names the screen and the role answers a question the translator would otherwise have to ask, before it is asked.

Related articles