Prompts as Code
Keep prompts in version control as templates with named parameters, and keep the instruction separate from the data.
string.Template(text).substitute(ticket=body, locale="en-GB")A prompt is program text. If it lives in a string literal halfway down a handler, nobody can review a change to it, nobody can diff two versions, and nobody can tell which prompt produced yesterday's output. Treat prompts the way you treat SQL: named, versioned, parameterised, in files.
Templates with real parameters
Interpolation has three spellings in Python and they produce identical strings. f"Classify: {ticket}" is the shorthand, evaluated at the point of writing. "Classify: {ticket}".format(ticket=body) is the same with the text separable from the values. string.Template("Classify: $ticket").substitute(ticket=body) is the longhand. The last two can both live in a file and be filled in later; an f-string cannot, because its interpolation happens where it is compiled, so an f-string in a file is just text. Of the two, prefer Template for prompts: prompt text is full of literal braces, and a JSON example inside a .format() template raises KeyError, while $ placeholders ignore braces entirely. Use substitute rather than safe_substitute so a missing parameter raises KeyError instead of shipping.
Instruction and data are not the same thing
Put the instruction first and the caller's data inside a clearly delimited block, for example between <ticket> tags, and say in the instruction that only the content of that block is data. This does not make prompt injection impossible, but an unfenced prompt where user text and your instructions are one undifferentiated paragraph makes it trivial.
Making a change reviewable
One file per prompt, a version string in the module, and the version recorded with every stored output. Then a diff is a diff, a rollback is a revert, and a regression has a commit attached to it.
Example
When to use it
- Reviewing a prompt change in a pull request like any other diff
- Tracing a bad answer in production back to the exact prompt version
- Reusing one prompt across a web handler and a batch job
- Keeping user-submitted text from being read as instructions
More examples
Prompts as files, loaded once
The layout that makes prompts reviewable: one .txt per prompt, loaded at import, with a version constant that goes into your logs and output rows. A pull request then shows the prompt diff next to the code that changed with it, and a bad answer in production can be traced to an exact prompt text.
# app/prompts/__init__.py
from pathlib import Path
from string import Template
DIR = Path(__file__).parent
VERSION = "2026-10-02" # bump on every prompt edit; log it with outputs
def load(name: str) -> Template:
# Read at import, not per call: a prompt file change should require a
# deploy, so staging and production cannot silently diverge.
return Template((DIR / f"{name}.txt").read_text(encoding="utf-8"))
TRIAGE = load("triage")
SUMMARY = load("summary")
# app/triage.py
from app import prompts
def build(ticket: str) -> str:
return prompts.TRIAGE.substitute(ticket=ticket)Instruction, data, and the turn boundary
Splitting one logical prompt across the two places it belongs: rules and output format in the system prompt, the per-request data in the user turn. This keeps the expensive, unchanging half identical byte for byte between calls, which is what prompt caching needs, and it keeps user-supplied text out of the channel that carries your instructions.
reply = client.messages.create(
model="claude-opus-5-5", max_tokens=512,
# Standing rules go in the system prompt, which is also the stable prefix
# that prompt caching can reuse across calls.
system=prompts.TRIAGE_SYSTEM.substitute(labels="bug, billing, question"),
messages=[{
"role": "user",
"content": prompts.TRIAGE_USER.substitute(ticket=untrusted_body),
}],
)A test that fails when a prompt changes
Cheap, offline tests that catch the two failure modes of templated prompts: a renamed placeholder that silently stops being filled, and data that escapes its delimiters. They run in milliseconds and never call the model, so they belong in the same CI job as the rest of the unit tests.
# tests/test_prompts.py
from app import prompts
def test_triage_renders_and_fences_the_data():
rendered = prompts.TRIAGE.substitute(ticket="refund please")
assert "<ticket>\nrefund please\n</ticket>" in rendered
assert "$" not in rendered # no placeholder survived
def test_triage_requires_its_parameter():
import pytest
with pytest.raises(KeyError):
prompts.TRIAGE.substitute()
Discussion