skip to content

Why do curly braces in user text break a LangChain f-string prompt template?

level: seniorimportance: should knowfreq 47%

answer

  1. braces are the variable delimiter
  2. JSON in a template looks like a field
  3. double them to escape
  4. values are never re-parsed
  5. never f-string user text into a template

basics

~20 s

By default a LangChain template uses f-string formatting, so every {...} in the template body is parsed as a variable slot. Literal braces — JSON examples, code, regexes — must be doubled as {{ and }}, or formatting fails with a missing-key error.

solid answer

~50 s

LangChain templates default to `template_format="f-string"`, meaning the template text is scanned for `{name}` slots and the discovered names become `input_variables`. Any literal brace therefore looks like a variable: a template containing `Reply as {"status": "ok"}` picks up a nonsense variable name from the JSON, and formatting raises a key error. The fixes, in order of preference: pass the brace-laden text in as a *variable value* — substituted values are never re-parsed as templates, so they are safe by construction; double the braces (`{{`/`}}`) when the literal must live in the template body; or switch that template to `template_format="mustache"`, where the delimiter is `{{name}}` and single braces are literal. The failure mode that actually bites in production is the first case reversed — building a template by interpolating untrusted text into the template string, which turns any user-supplied brace into a crash and any `{secret_var}` into an unintended substitution.

code

python · 19 lines
python
from langchain_core.prompts import ChatPromptTemplate

# broken: the JSON braces are parsed as a template field
bad = ChatPromptTemplate.from_messages([
    ("human", 'Reply as {"status": "ok"} about {topic}'),
])
# bad.input_variables now contains a bogus name taken from the JSON,
# and bad.format_messages(topic="uptime") raises a missing-key error

# fix 1: escape the literal braces
ok = ChatPromptTemplate.from_messages([
    ("human", 'Reply as {{"status": "ok"}} about {topic}'),
])
print(ok.input_variables)                    # ['topic']
print(ok.format_messages(topic="uptime"))

# fix 2: pass brace-laden text as a value; values are not re-parsed
safe = ChatPromptTemplate.from_messages([("human", "Analyse:\n{payload}")])
print(safe.format_messages(payload='{"status": "ok"}'))

go deeper

for a junior

Know that {name} marks a variable in the default template format, so a literal curly brace in the template text must be written twice as {{ or }}.

for a middle

Explain that variable names are parsed out of the template at construction and that a colon inside braces is read as a format spec, which is why JSON examples produce confusing missing-key errors.

for a senior

Draw the line between template and data: user text goes in as a value, never interpolated into the template string, because that turns a user's brace into a crash or an unintended variable substitution.

for a principal

Set the codebase rule — prompts as reviewed literals in one module, input_variables asserted in tests, adversarial formatting cases in CI — so this whole class of bug cannot reach production.

## What the default format actually does When you write `PromptTemplate.from_template("Summarize: {doc}")`, LangChain parses the string using Python's format-string grammar to discover variable names, and later substitutes values using that same grammar. The default `template_format` is `"f-string"`. Two consequences follow, and both surprise people. First, the parser has no idea which braces you meant as variables. Every `{` opens a field. A template containing a JSON example, a code snippet with a dict literal, a LaTeX expression or a regex quantifier such as `\d{3}` is full of accidental fields. Second, the format grammar is richer than "name in braces": a colon inside the braces starts a format spec. So `{"status": "ok"}` is read as a field named `"status"` with format spec ` "ok"`, which is why the resulting error message often names something that looks nothing like a variable you wrote. ## The three fixes **Pass it as a value.** This is the right answer almost always. Values substituted into a template are inserted literally; they are not scanned again for braces. So a document, a user question, a retrieved chunk, or a JSON blob handed in as `{context}` can contain any braces at all and nothing breaks. ``` prompt = ChatPromptTemplate.from_messages([("human", "Analyse this:\n{payload}")]) prompt.format_messages(payload='{"status": "ok"}') # fine ``` **Double the braces.** When the literal genuinely belongs in the template — you are showing the model the output schema you want — escape it: ``` ChatPromptTemplate.from_messages([ ("system", 'Reply as JSON: {{"status": "ok", "score": 0.0}}'), ("human", "{input}"), ]) ``` The rendered output contains single braces. This is correct but fragile in review: the doubling is easy to lose when someone reformats the string or copies the schema from elsewhere. **Change the template format.** Templates accept `template_format="mustache"`, where variables are `{{name}}` and single braces are ordinary characters — a good fit for prompts that are mostly JSON with a couple of slots. Jinja2 is also supported, but it is a full expression language, so never point it at a template string that any untrusted input helped construct. ## The production failure that matters The escaping puzzle is a nuisance; the dangerous version is building templates dynamically. Code that does ``` ChatPromptTemplate.from_messages([("human", f"Answer this: {user_question}")]) ``` has put user text into the *template*, not into a value. Now every brace the user types becomes a template field. At best you get an error whenever someone pastes JSON or code — an availability bug reachable by any user, and one that only shows up for a subset of inputs, so it survives testing. At worst, if the user writes a name that happens to match a real variable in scope, their text is replaced by that variable's value, which can be internal context they were never meant to see. The rule that prevents the whole class: **the template is code, the inputs are data.** Templates are written by developers, live in source, and are reviewed. Anything that arrives at runtime goes in as a variable value. If you find yourself f-stringing into a template argument, that is the bug. The same rule explains a related subtlety: a concrete message object placed into `from_messages` is inserted literally rather than templated, so its content is not scanned for braces. That can be a deliberate escape hatch for fixed text containing braces. ## Diagnosing it Symptoms: a missing-key error naming something that is not one of your variables; `prompt.input_variables` containing entries you never declared; a prompt that works in tests and fails on the one customer who pastes a stack trace. The first check is always `print(prompt.input_variables)` — if there is a stray name in there, an unescaped brace is the cause. The second is to confirm no template string was built with an f-string or `.format()` or concatenation at runtime. ## Guardrails worth having Keep prompts in a dedicated module as literals, so review sees them. Assert on `input_variables` in a unit test for each prompt — that single assertion catches an accidental brace, a renamed variable and a typo'd placeholder key in one line. Add a test that formats each prompt with adversarial values containing braces, quotes and newlines. And when a prompt must embed a schema, prefer describing the structure via a structured-output constraint over pasting a brace-heavy example into the template.

  • Why is interpolating user text into the template string a security issue and not just a crash?
    Because the interpolated text is then parsed as template syntax. If a user writes a brace-wrapped name that matches a variable in scope — say a context or system field — their input is replaced by that variable's value, leaking data they should never see. The crash case is the visible half; the substitution case is silent. Templates are code, runtime input is data.
  • When would you switch a template to mustache format?
    When the template body is mostly literal braces — a JSON schema, a code block, a config sample — and doubling every brace makes it unreadable and easy to break in review. Mustache uses `{{name}}` as its delimiter, so single braces pass through untouched. It is a per-template choice via `template_format`, so you can use it only where it pays.
  • What single assertion in a unit test catches most of these bugs?
    Asserting each prompt's `input_variables` equals the exact expected list. A stray unescaped brace adds a name you never declared, a typo removes one you did, and a renamed variable shows up as a mismatch — all caught without a model call. Pair it with a formatting test that passes values containing braces, quotes and newlines.

saying these in an interview costs you the question

  • Building a template with an f-string over user input
  • Thinking substituted values are re-scanned for braces
  • Escaping with a backslash instead of doubling the brace
  • Assuming mustache is the default template format
  • Pointing jinja2 templating at runtime-assembled template text

context