Skip to content

Prompt Templates and Variables Without a Framework

6 min read · updated August 3, 2026

Prompt templating looks like the easiest problem in the stack, right up to the point where a JSON example in your prompt collides with your format string and production starts throwing KeyError on a brace.

The brace collision

Prompts contain JSON, because you are showing the model the output shape. Format strings use braces for variables. These two facts meet badly in every language that borrowed Python’s syntax:

>>> tmpl = 'Classify {text}. Reply as {"label": "...", "score": 0.0}'
>>> tmpl.format(text="hello")
Traceback (most recent call last):
KeyError: '"label"'

>>> 'Reply with a closing brace: }'.format()
Traceback (most recent call last):
ValueError: Single '}' encountered in format string

The usual fix is to double every brace in the literal JSON. It works and it is a trap: the doubled braces are now part of a string that humans edit, copy into a playground and paste back, and the day somebody pastes an un-doubled example the failure is a KeyError naming a fragment of your own schema.

Use a delimiter that cannot appear in JSON instead. Python’s string.Template uses $name and leaves braces entirely alone:

from string import Template

TMPL = Template('Classify $text. Reply as {"label": "...", "score": 0.0}')
TMPL.substitute(text="hello")      # fine
TMPL.substitute()                  # KeyError: 'text'  <- loud, which is the point

Note substitute and not safe_substitute. The “safe” variant leaves unresolved placeholders in the string, so a typo ships a prompt containing the literal text $custmer_name to your users. Loud beats safe here.

The same collision appears in every language with brace delimiters, and the usual escape hatch — a full template engine — brings its own problems to prompts specifically. Jinja-style engines strip and collapse whitespace by rules you now have to know, and whitespace in a prompt is load-bearing: it changes tokenisation and it changes whether a cached prefix matches. Autoescaping, designed to make HTML safe, will helpfully turn a quotation mark in a customer’s ticket into &#34; inside your prompt. Neither is a reason never to use one. Both are reasons to know which behaviours you have switched on.

One more mundane hazard: indentation. A prompt written as an indented triple-quoted string inside a class carries that indentation into every line, so the model receives a document in which every paragraph starts with eight spaces. It usually copes — and it also often copies the shape into its answer. Dedent explicitly, or keep long prompts in their own file where the left margin is the left margin.

The failures that do not throw

The brace error is the friendly one; it stops the process. These do not:

  • Rendering None. A missing lookup interpolates the string None — or undefined in TypeScript, or <nil> in Go — into the middle of an instruction. The model does something reasonable with it and nobody notices for a month. Reject null values at render time.
  • Rendering an empty list as []. “Here are the relevant documents: []” is a prompt that will be answered from the model’s memory. Branch on empty before rendering and use a different template.
  • Invisible characters from a paste. Editing a prompt in a rich-text field or a browser converts straight quotes to typographic ones and ordinary spaces to U+00A0. The prompt looks identical in review, tokenises differently, and no longer matches the cached prefix it matched yesterday.
  • Trailing whitespace before the model’s turn. Tokenisers usually attach a leading space to the following word, so a prompt ending in a space asks the model to continue from a token boundary that does not occur naturally in its training data. Provider documentation has warned about this since the completion era; strip the right-hand end.
  • User content that closes your delimiter. If your template wraps input in <ticket>…</ticket>, a ticket containing that closing tag ends the section early. Escape or strip it at render time — this is the prompt-injection surface that is actually your bug rather than the model’s.

Types deserve their own line. A float rendered by default formatting can arrive as 0.30000000000000004; a date as datetime.datetime(2026, 8, 3, 0, 0); a decimal as Decimal('19.99'); and an amount held in cents as 1999, with nothing to say which unit it is. The model will interpret each of them as best it can, which is worse than an exception because it is invisible. Format values explicitly at the boundary — one function per value type — rather than letting the language decide what a number looks like today.

A templating layer worth having

The whole thing is a typed function that returns messages, plus three rules: no logic in the template, no unvalidated inputs, no silent defaults.

// prompts/triage.ts  — one file per prompt, exported as data + a renderer.

export const TRIAGE_V3 = {
  id: "triage",
  version: 3,
  system: [
    "<role>You triage inbound support tickets.</role>",
    '<output>{"category":"refund|technical|billing|other","confidence":0-1}</output>',
  ].join("\n"),
} as const;

type TriageVars = { ticket: string; locale: string };

const escape = (s: string) => s.replace(/<\/?ticket>/gi, "");

export function renderTriage(v: TriageVars) {
  if (!v.ticket?.trim()) throw new Error("triage: empty ticket");
  return [
    { role: "system" as const, content: TRIAGE_V3.system },
    {
      role: "user" as const,
      content:
        `<ticket locale="${v.locale}">\n${escape(v.ticket)}\n</ticket>\n` +
        "Reply with the JSON object only.",
    },
  ];
}

What this buys, in order of importance: the system string is a constant, so it is a stable cache prefix and a reviewable diff; the variables are typed, so a renamed field is a compile error rather than an undefined in a prompt; escaping happens in one place; and the function is pure, which makes the next section possible.

Notice that the renderer returns a message list rather than a string. That is the right boundary: roles, tool definitions and prefills are all part of the prompt, and a layer that only produces strings pushes the interesting decisions back into the caller. Everything that varies per call — the model, the temperature, the schema — travels alongside the messages, so one object describes the whole request and can be logged, diffed and replayed.

Golden tests, and why bytes matter

Render with fixed inputs and assert the result equals a checked-in fixture, byte for byte. It is the cheapest test in the repository and it catches the class of change that no other test sees: a reformatting pass that moved a newline, an editor that trimmed trailing whitespace inside a block, a merge that reordered two sections.

Those changes are not cosmetic. A cached prefix matches on exact tokens, so a stray newline in the system block turns every request into a cache miss — the same money discussed on the system-prompt page, lost to a whitespace diff nobody would flag in review. A golden test makes it a red build with a visible diff instead.

Two notes on the fixtures themselves. Use synthetic inputs: a golden file containing a real customer ticket is a copy of production data living in your repository forever, and it will outlive the reason somebody added it. And render the fixture with the function production uses rather than a test double, or the only thing you have asserted is that your test helper is stable.

When to reach for a framework

Plain strings stop being enough at three specific points, and none of them is “the prompt got long”:

  • You render chat templates yourself. If you serve open weights and have to apply each model’s own template, use the tokeniser’s implementation rather than reproducing marker tokens by hand.
  • You want prompts compiled against a metric. That is what DSPy-style tooling is for, and it is a different activity from templating.
  • Non-engineers edit prompts. Then you need a store, a review flow and a rollback — but keep the rendered result mirrored into git anyway, or you lose the diff that explains the incident.

The principle underneath all of this is that a prompt is an artefact with a schema, not a string with holes in it. Once the inputs are typed, the escaping is centralised, the output is a message list and the whole thing is snapshot-tested, the question of which templating library to use stops being interesting — which is usually the sign that a boundary is in the right place.

Prompt Templates and Variables Without a Framework · Multigrid