Skip to content

Writing System Prompts for a Product Used in Many Languages

9 min read · updated August 11, 2026

The system prompt is the file that accumulates. Every incident adds a line, every edge case adds a clause, and after a year it is the single most valuable and least reviewed artefact in the product. Fork it per locale and you have multiplied that problem by the number of languages you support, with no mechanism for keeping the forks in step.

What goes wrong with one prompt per locale

The forked approach fails in a specific and predictable order. First, a policy change lands in the English prompt and not the other nine, so the product behaves differently by language for reasons nobody intended. Then somebody notices, and the fix is to translate the English prompt again — which overwrites the per-language corrections that were added for good reasons, usually by the one person who speaks that language. Then those corrections get re-added as comments nobody reads. By the third round, no two files agree and no one can say which differences are deliberate.

The underlying issue is that a forked file cannot express intent. A diff between the German and English prompts shows a hundred differences, of which ninety-eight are just translation and two are real policy. Structure fixes this by making the two real ones the only thing in the German file.

The split

Everything in a system prompt is one of two kinds of rule, and the test is simple: would a competent speaker of any language state this rule the same way?

  • Language-agnostic. Identity and scope, refusal policy, tool-use rules, the output contract, safety constraints, escalation triggers, what the assistant may and may not promise. These are facts about your business. They do not become different facts in Dutch.
  • Language-specific. Register and address form, script variant, quotation and punctuation conventions, number and date formatting, name order, honorifics, units, and the examples. These are facts about a language or a locale, and stating them in a shared file means stating them wrongly for everyone but one.

One test catches most misfilings: if a rule mentions a specific string the model should output, it is probably language-specific. If it describes a decision the model should make, it is probably agnostic. “Never quote a delivery date” is agnostic. “Sign off with Mit freundlichen Grüßen” is not.

What an override file actually contains

Keep the override to a fixed set of fields, so that adding a language is filling in a form rather than writing prose. A missing field should be a type error, not an omission — including the ones whose answer is “this language does not have that”, because an explicit “no grammaticalised formality here” tells the next reader the question was asked.

export const de: LocaleOverride = {
  tag: "de",
  englishName: "German",
  endonym: "Deutsch",

  // Address form. A grammatical rule, not an adjective — see the register page.
  addressForm:
    "Use Sie, Ihnen, Ihr throughout, including imperatives (Bitte prüfen Sie …). " +
    "Never du, dich, dir, dein.",

  // Typography the model gets wrong if you do not say it.
  typography:
    "Use German quotation marks „ and “. Decimal comma, thousands separator " +
    "as a full stop or a narrow space. Dates as 3. Juni 2026 or 03.06.2026.",

  // Things this language does that others do not.
  notes:
    "Nouns are capitalised; do not apply English title casing to headings. " +
    "Prefer the formal Anrede Sehr geehrte Frau X when the surname is known.",

  // Two or three examples, written natively.
  examples: [/* … */],

  // Anything that must never be translated, in this language's context.
  doNotTranslate: ["Multigrid", "Pro plan", "API key", "webhook"],
};

The typography field earns its place quickly. German quotation marks, French spacing before a colon or a question mark, the Spanish opening question mark, the Japanese full stop and the CJK comma are all things a model produces correctly most of the time and incorrectly enough of the time to look careless. Related locale formatting decisions — which are the same problem outside the prompt — are covered in decimal comma versus decimal point and handling day-month and month-day date formats.

Composition order

Composition is not concatenation in an arbitrary order. Two rules decide it, and they follow from how a model weighs context: later text is closer to the generation point, and a specific instruction that appears after a general one reads as an exception to it rather than a contradiction of it.

function systemPrompt(locale: LocaleOverride) {
  return [
    CORE_IDENTITY,        // who this assistant is, what it will not do
    CORE_POLICY,          // refusals, escalation, what it may never promise
    OUTPUT_CONTRACT,      // English field names and enum values, unchanged
    "",
    "Language: " + locale.englishName + " (" + locale.endonym + ", " + locale.tag + ").",
    locale.addressForm,
    locale.typography,
    locale.notes,
    "",
    "Never translate the following terms: " + locale.doNotTranslate.join(", ") + ".",
    "",
    "Examples:",
    renderExamples(locale.examples),
  ].join("\n");
}

Core policy goes first because it must not read as an exception to anything. The language block goes after it because per-language typography and address form legitimately are exceptions — they refine the general rule rather than replacing it. The examples go last because they are the strongest signal about surface form and you want them nearest the generation boundary. The output-language reminder, if you use one, goes after the user turn entirely; that is argued in forcing a model to always respond in one language.

The do-not-translate glossary

This is the field most localisation setups omit and the one that generates the most support tickets. A model writing German will helpfully translate your product nouns: the Pro plan becomes Pro-Tarif, the API key becomes API-Schlüssel, a webhook becomes an Ereignis-Hook. Each translation is reasonable German and each one names something the user cannot find in your interface, because your interface says “Pro plan”.

The glossary should contain, at minimum: product and plan names, UI labels the user will have to locate, error codes and status strings, third-party product names, and any term of art your documentation uses consistently. If your interface itself is localised, then the glossary is not a do-not-translate list but a translate-exactly-this-way list, and it should carry the target string rather than only the English one — otherwise the model invents a second translation and the reply and the interface disagree.

Keep the glossary next to the interface strings, not next to the prompt. The failure mode is a UI label being renamed and the prompt glossary keeping the old one, which is worse than having no glossary: the model now confidently names a button that no longer exists.

Finally, make adding a language a checklist with a test at the end of it. The override compiles, the examples are native, the register rule is grammatical, the glossary is filled, and the shared suite passes for the new tag before it appears in a language picker. The suite is the subject of testing whether a prompt works the same across languages, and it is what turns “we support twelve languages” from a claim into something checked on every deploy.