Skip to content

Building a Prompt Template With Language as a Variable

9 min read · updated August 11, 2026

The usual first attempt is one prompt file per locale, machine translated from the English one. It works until the eighth language, then every change has to be made eight times and nobody can tell which of the eight is out of date. The alternative is one template with language as a parameter — but only some of the prompt is allowed to vary, and picking the wrong parts is what makes the single-template approach fail.

A prompt has four parts and they do not all switch

Separate the prompt into four things before deciding anything about language. They have different jobs and they answer the question differently.

  • The instruction. What the model is being asked to do, its constraints, its refusal policy. This is the part that is usually translated and usually should not be. It is the one piece with no reader other than the model, and translating it is how the eight-file problem starts. Keep one canonical instruction, in one language, and add an explicit output-language directive to it.
  • The examples. Few-shot demonstrations. These must be in the target language, without exception. An example does not teach the task — the instruction does that. An example teaches surface form: sentence length, register, punctuation conventions, how a list is introduced, whether a currency symbol goes before or after the number. All of that is language-specific and none of it survives a demonstration written in English.
  • The output contract. Field names, enum values, tool names, anything your code branches on. These are identifiers. They stay fixed, in English, in every language. See below — this is the one that quietly breaks production.
  • The user-visible text. The free-text fields inside the contract. This is what actually has to come out in the target language, and it is usually a much smaller part of the prompt than people assume.

The default, then, is: instruction fixed, examples switched, contract fixed, output text switched. Per-language deviation from that default is possible and sometimes necessary, but it should be a deliberate override rather than the structure of the system. That override file is the subject of structuring a system prompt for a product used in many languages.

Identifying the language properly

The parameter should be a BCP 47 language tag, not a two-letter code and not an English language name. Two-letter codes are ambiguous in exactly the cases that matter: zh does not say whether you want Simplified or Traditional characters, and the tag that does is zh-Hans or zh-Hant. sr does not say Cyrillic or Latin; sr-Cyrl does. no is Norwegian in general where the written standards are nb (Bokmål) and nn (Nynorsk), which differ enough that a user notices. pt and pt-BR are different products to a Brazilian reader.

When you put the language into the prompt text, give the model three things: the tag, the English name, and the endonym — the name of the language in itself. The endonym is doing real work. It is written in the target language’s own script, so it is itself a signal about which script the answer should use, and it disambiguates cases the English name does not.

Respond in Brazilian Portuguese (português do Brasil, BCP 47: pt-BR).
Use the Simplified Chinese script (简体中文, BCP 47: zh-Hans).

The IETF’s BCP 47 is the tag registry everything else builds on, and the Unicode CLDR project publishes the language names and endonyms you would otherwise be hand-maintaining. Both are worth reading before inventing a locale format: the IETF’s BCP 47 and Unicode’s CLDR.

The template

A language entry holds the tag, the two names, the register decision, and the examples. Everything else is shared. The instruction below is a real one for a support-reply task, not a placeholder.

type LanguageProfile = {
  tag: string;          // BCP 47, e.g. "de", "pt-BR", "zh-Hans"
  englishName: string;  // "German"
  endonym: string;      // "Deutsch"
  register: "formal" | "informal";
  registerRule: string; // the grammatical instruction, not an adjective
  examples: Array<{ input: string; output: string }>;
};

const de: LanguageProfile = {
  tag: "de",
  englishName: "German",
  endonym: "Deutsch",
  register: "formal",
  registerRule:
    "Address the customer with Sie, Ihnen and Ihr. Never use du, dir or dein, " +
    "including in imperatives.",
  examples: [
    {
      input: "Meine Rechnung vom 3. Juni ist doppelt abgebucht worden.",
      output:
        "Vielen Dank für Ihre Nachricht. Ich habe die doppelte Abbuchung vom " +
        "3. Juni geprüft und die Rückerstattung veranlasst.",
    },
  ],
};

function buildPrompt(profile: LanguageProfile, ticket: string) {
  const shots = profile.examples
    .map((e) => "User: " + e.input + "\nAssistant: " + e.output)
    .join("\n\n");

  return [
    // 1. instruction — one canonical copy, never translated
    "You are a support agent. Read the customer message and draft one reply.",
    "Do not promise a refund date. Do not invent an order number.",
    "If the message is not a support request, set intent to other and leave body empty.",
    "",
    // 2. output contract — English identifiers, fixed in every language
    'Return JSON: {"intent": "billing" | "shipping" | "other", "body": string}',
    "The body field must be written in " +
      profile.englishName + " (" + profile.endonym + ", " + profile.tag + ").",
    profile.registerRule,
    "",
    // 3. examples — target language only
    "Examples:",
    shots,
    "",
    "Customer message:",
    ticket,
    "",
    // 4. the directive repeated last, immediately before generation
    "Write the body field in " + profile.endonym + ".",
  ].join("\n");
}

Two details in there are load-bearing. The output-language directive appears twice, once with the contract and once as the final line before the model starts generating, because proximity to the generation point matters more than repetition does — the reasoning is in forcing a model to always respond in one language. And registerRule is a sentence about grammar rather than the word “formal”, because “formal” means different things in German, Japanese and English.

Why the schema stays in English

The failure that catches teams is localising the parts of the output that look like text but are actually identifiers. If the German prompt asks for "absicht" instead of "intent", or allows the value "versand" instead of "shipping", then the switch statement downstream falls through to its default branch and every German ticket is routed as if it were uncategorised. Nothing errors: the JSON parses, the enum is a plausible string, the reply body is perfectly good German.

The same applies to tool names, function parameter names, and any sentinel string the model is asked to emit — NO_ANSWER, ESCALATE, none. Treat all of them as code. If a human ever has to read an enum value, translate it at the presentation layer where you already translate everything else.

The reverse trap is real too. Length limits expressed in words are not portable: “under 100 words” means nothing usable in Thai or Chinese, which do not put spaces between words, and German compounding makes one word carry what English spends four on. Constrain by characters, or by a per-language token budget — see what German compounds cost in tokens.

Adding the eleventh language

The point of the structure is that adding a language is a data change, not a prompt rewrite. The checklist is short and every item is a thing that has bitten somebody.

  1. Pick the tag, with a script subtag if the language is written in more than one script and with a region subtag if the regional variants are different enough to be noticed.
  2. Write the register rule as a grammatical instruction. If the language has no grammaticalised formality, say so explicitly rather than leaving the field empty, so the next person knows it was considered.
  3. Get two or three examples written by somebody who speaks the language, not translated from the English examples. A translated example demonstrates translated prose, which is exactly the register you are trying not to produce — see why literal prompt translation produces worse output.
  4. Run the existing test suite for the new tag before shipping it, with at minimum an output-language check and a schema check. The harness for that is in testing whether a prompt works the same across languages.
  5. Check the token cost of a representative reply. Output length in tokens varies by a large factor across languages for the same content, and a max_tokens that is generous in English can truncate mid-sentence in Hindi or Thai.

What you should not do is add a per-language copy of the instruction block “just for this one”. That is how the eight-file problem comes back, one exception at a time. If a language genuinely needs a different instruction, put the difference in the profile as a named override field so it is visible in a diff.