Languages and locale codes

Which key a translated catalog text is stored under — market locale versus storage key, the tenant settings that decide it, the write key and read order, what the platform rejects, and how imports merge languages.

Product names, descriptions, category and attribute labels and every other translated catalog text sit in a map keyed by a locale code. Which code is the right key is not something a client decides. The tenant decides it once in its market settings, the markets app resolves it, and every writer and reader follows that answer.

Write under the write key, read along the read order, and never hard-code de, de-DE or de_DE.

This applies to catalog and search data: products, categories, attributes, options, families, reference entities, asset families and association types. Interface texts in a manifest (title, label in cockpit.json or settings.json), mail templates and value lists stay keyed by the bare language (en, de) and are not affected.

Market locale and storage key are two different things

ExampleWhere it comes from
Market localede-ATA market registers it. It is what a storefront asks for: "render this page for Austria in German". Always language-COUNTRY.
Storage keyde-AT or deThe key a text is written under. It depends on the tenant's locale_granularity setting.

In a tenant that keeps text per region the two are the same code. In a tenant that keeps text per language, every German-speaking market reads one de text, and the market locale only decides the read order.

The settings

All three are tenant-wide markets settings. Changing one moves no stored text (as of 2026-10); see Changing a setting later.

locale_granularity — how translations are kept

ValueWrite key for the market locale de-ATRight when
regional (default)de-AT. de-DE, de-AT and de-CH are three separate texts.The wording actually differs by country.
languagede. One text every German-speaking market reads.It does not, and you would rather maintain one German.

regional is the default of the setting. Most tenants run language, so a client that assumes a regional key writes past the text everyone else reads.

locale_fallback — what a missing translation falls back to

ValueAfter the exact key holds nothing, a read tries
language (default)The bare language: de-AT → de.
default_localeThe market's default locale: en-GB → de-DE.
noneNothing. The gap shows, which is the honest answer when a missing translation must be noticed.

fallback_locale — the tenant's main locale

A full code, default de-DE. It is the default_locale fallback wherever no market answers (the tenant-wide policy below), and its write key is the tenant's main language in search.

Write key and read order

Neither setting is handed to a client as a name to interpret. GET /v1/markets/{id}/context carries a locale_policy with, per locale, the key to write under and the keys to read in order. GET /v1/markets/locale-policy answers the same for the whole tenant: the union of every market's locales, each naming the markets that registered it.

locale_policy
{
  "granularity": "language",
  "fallback": "language",
  "locales": [
    { "code": "de-AT", "language": "de", "write": "de", "read": ["de-AT", "de"], "markets": ["at"] }
  ]
}

A read always starts at the exact code. A fallback fills a gap and never outranks a text that is stored. That is why an Austrian override under de-AT still wins in a tenant that writes de.

locale_granularitylocale_fallbackMarket localewriteread
regionallanguagede-ATde-ATde-AT → de
regionaldefault_localeen-GBen-GBen-GB → de-DE
regionalnonede-DEde-DEde-DE
languagelanguagede-ATdede-AT → de
languagedefault_localeen-GBenen-GB → en → de-DE

The default_locale rows assume a market whose default locale is de-DE. In the tenant-wide answer, which stands in no market, that fallback is the tenant's fallback_locale setting.

Read the policy once per request or cache it briefly. Do not derive it from the two setting names yourself: that is how one client reads de-DE while another wrote de, and the text looks missing to the first.

From an input to a write key

An import file or an integration often carries a locale in some other spelling. The platform normalises it and maps it to the tenant's write key. The same input gives different answers in different tenants:

InputTenant per region, markets de-DE and en-USTenant per region, markets de-DE and de-ATTenant per language, markets de-DE and de-AT
de-DEde-DEde-DEde
de_DE, de-dede-DEde-DEde
de, deu, gerde-DEambiguous: choose de-DE or de-ATde
de-CHnot configurednot configuredde (the language is configured)
plnot configurednot configurednot configured
DE, german, de-DEUinvalid formatinvalid formatinvalid format

Three-letter codes are the ISO 639-2 codes BMEcat files use (deu, ger, eng, fra, …). They map to their two-letter language.

What the platform rejects

A key the tenant cannot read is worse than an error: it is stored, and no storefront ever shows it. So a write fails instead of guessing.

CaseExampleAnswer
Malformed keyDE, german, de-DEU as a key in labels or a locale bucket422, naming the key
Language no market configurespl in a tenant with only German and English markets422, naming the languages the tenant has
Ambiguous languagede in a regional tenant with de-DE and de-AT422, naming the codes to choose from
A well-formed key that is not the write keyde-DE in a tenant that keeps text per languageaccepted, and reported as drift (below)

A stored key has the shape ll or ll-CC: two lower-case letters for the language, optionally a hyphen and two upper-case letters for the country. An import does the same check before it writes anything, so a file with an unresolvable language fails as a whole with the mapping it needs, instead of loading half a catalog. A catalog import for a tenant whose markets configure no locale at all fails the same way, instead of writing under a guessed de-DE.

Imports make these checks already. Across the products API they are being rolled out (as of 2026-10): until a route enforces them, it stores what you send, so a bare de that fits no or several regions stays de. Validate against the policy on your side, as described above.

Once the products API enforces them, a spelling that only differs in form (de_DE, de-de, deu) is normalised and stored under the write key instead of refused. Only a key that cannot be read as a locale (DE, german, de-DEU) fails as invalid_locale. The other refusals answer unknown_locale and ambiguous_locale, and two spellings of one key with different values in one write answer duplicate_locale. The error's details names the allowed keys (allowed) and, per offending key, its path, the reason and the candidates (keys). Direct writes to the table bypass the API and are held to the ll / ll-CC shape only.

Drift is reported, not rejected

A key that is well formed and belongs to the tenant, but is not the write key (de-DE in a tenant that writes de), is still readable through the read order, so it is not refused. Typically it comes from a customer pipeline that hard-codes a region. The platform counts such keys per entity every night, together with products that have no name in the default language, and Cockpit shows the result on the dashboard as a data-quality item. The nightly report is being rolled out (as of 2026-10).

Importing several languages

A BMEcat file can carry texts in several languages, and catalogs often arrive as one file per language. The import profile decides how an incoming language meets the languages already stored:

ModeWhat happensUse it when
merge (default)Every value in the file overwrites its attribute or label. Everything else stays, including other languages.Regular updates.
replace_localesThe languages contained in the file are replaced as a whole. Languages the file does not contain are untouched.A supplier sends one complete file per language.
replaceThe stored document is replaced by the file.A full reload where the file is the only truth.

The mode applies to product values and to category, attribute group and attribute labels. With merge or replace_locales, two files, one in de and one in en, never erase each other, whichever arrives first. Only replace loses the language a file does not carry.

A profile can also run as a dry run: the file is read, resolved and merged, then nothing is kept, and the job reports what it would have written along with warnings, for example a locale the file spells differently from the write key. When a file language does not resolve on its own, map it in the profile's BMEcat options ("locales": {"deu": "de-AT"}).

The BMEcat locale option resolves to the tenant's write key: see Import a BMEcat catalog.

Changing a setting later

Switching locale_granularity changes the key a client writes from then on. Texts already stored stay under their old key (as of 2026-10). The read order keeps most of them readable: a tenant that moves from language to regional with locale_fallback: language still reads its old de texts through de-AT → de. With locale_fallback: none it does not, and German texts disappear from the storefront. Check the fallback before you switch.

A search collection with translated fields holds one document per product and write key, not per market locale: de and en in a tenant that keeps text per language. Which products get a document in a language:

  • The main language, the write key of fallback_locale, always holds every product. A product without its own text there is filled from the field's other sources and marked untranslated.
  • Every other language follows the products setting search_untranslated, per market: hide (default) publishes a product only where it has its own text in that language. fallback publishes every product and fills the gaps from the main language. When several markets write the same language and one says fallback, that language uses fallback.

A tenant without market locales keeps one document per key that is stored. A change to either setting reaches the index within about ten minutes. See Markets settings.

A storefront asks for one locale with the X-Revenexx-Locale header. See Search collections.

Where to go next

Was this page helpful?