Version control prompt templates the same way you version control code: with git, schema validation, and automated tests. Treating prompts as first-class artifacts prevents silent regressions when models change, enables rollbacks, and makes collaboration possible across product and engineering teams. This guide walks through a complete setup you can drop into an existing repository.
Step 1: Choose your storage format
Store templates as structured files, not strings buried in application code. JSON or YAML both work; YAML is more readable for multiline prompts, JSON validates faster in CI. Pick one and stay consistent.
# templates/summarize/v1.yaml
name: summarize
version: "1.2.0"
model: "gpt-4o-mini"
parameters:
temperature: 0.2
max_tokens: 300
system: |
You are an executive assistant. Produce exactly three bullet points.
Each bullet must be under 25 words. No preamble, no formatting.
user: |
Summarize the following text:
{{content}}
Focus on decisions made, metrics mentioned, and action items.
variables:
- name: content
type: string
required: true
max_length: 50000
The variables block is your contract. It lets you validate inputs at render time and generate documentation automatically.
Step 2: Set up git repository structure
Organize templates by use case, not by model. Models change; use cases stay stable.
prompts/
├── templates/
│ ├── summarize/
│ │ ├── v1.yaml
│ │ └── v2.yaml
│ ├── classify/
│ │ └── v1.yaml
│ └── extract/
│ └── v1.yaml
├── schemas/
│ └── template.schema.json
├── tests/
│ ├── fixtures/
│ │ └── summarize_sample.txt
│ └── test_templates.py
├── render.py
└── .github/
└── workflows/
└── prompt-ci.yml
Commit the schemas/template.schema.json first. It becomes the source of truth for every template in the repo.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["name", "version", "model", "system", "user", "variables"],
"properties": {
"name": { "type": "string", "pattern": "^[a-z-]+$" },
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
"description": { "type": "string" },
"model": { "type": "string" },
"parameters": {
"type": "object",
"properties": {
"temperature": { "type": "number", "minimum": 0, "maximum": 2 },
"max_tokens": { "type": "integer", "minimum": 1 }
}
},
"system": { "type": "string", "minLength": 1 },
"user": { "type": "string", "minLength": 1 },
"variables": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "type", "required"],
"properties": {
"name": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
"type": { "enum": ["string", "integer", "number", "boolean", "array", "object"] },
"required": { "type": "boolean" },
"description": { "type": "string" },
"max_length": { "type": "integer", "minimum": 1 }
}
}
}
},
"additionalProperties": false
}
Step 3: Define template schema with variables
Variables are where most prompt bugs hide. A missing content field, a string where the model expects JSON, a 100k-token input that blows the context window — all preventable with schema validation.
Use a lightweight renderer that validates before interpolation. Here’s a production-ready implementation:
# render.py
import json
import yaml
from pathlib import Path
from string import Template
from typing import Any
from jsonschema import validate, ValidationError
SCHEMA_PATH = Path(__file__).with_name("schemas") / "template.schema.json"
TEMPLATES_DIR = Path(__file__).with_name("templates")
with open(SCHEMA_PATH) as f:
TEMPLATE_SCHEMA = json.load(f)
class PromptTemplate:
def __init__(self, path: Path):
self.path = path
with open(path) as f:
self.raw = yaml.safe_load(f)
validate(instance=self.raw, schema=TEMPLATE_SCHEMA)
self._check_variables()
def _check_variables(self) -> None:
"""Ensure every {{var}} in system/user has a declared variable."""
declared = {v["name"] for v in self.raw["variables"]}
for field in ("system", "user"):
used = set(Template(self.raw[field]).get_identifiers())
undeclared = used - declared
if undeclared:
raise ValueError(
f"{self.path}: {field} references undeclared variables: {undeclared}"
)
def render(self, variables: dict[str, Any]) -> dict[str, Any]:
"""Validate inputs, interpolate, return provider-ready payload."""
# Validate provided variables against declared schema
for var_def in self.raw["variables"]:
name = var_def["name"]
required = var_def["required"]
if required and name not in variables:
raise ValueError(f"Missing required variable: {name}")
if name in variables:
self._validate_type(name, variables[name], var_def)
# Interpolate
system = Template(self.raw["system"]).safe_substitute(variables)
user = Template(self.raw["user"]).safe_substitute(variables)
return {
"model": self.raw["model"],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"parameters": self.raw.get("parameters", {}),
}
def _validate_type(self, name: str, value: Any, var_def: dict) -> None:
expected = var_def["type"]
type_map = {
"string": str,
"integer": int,
"number": (int, float),
"boolean": bool,
"array": list,
"object": dict,
}
if not isinstance(value, type_map[expected]):
raise TypeError(f"Variable '{name}': expected {expected}, got {type(value).__name__}")
if expected == "string" and "max_length" in var_def:
if len(value) > var_def["max_length"]:
raise ValueError(f"Variable '{name}' exceeds max_length {var_def['max_length']}")
def load_template(name: str, version: str | None = None) -> PromptTemplate:
"""Load latest version if version omitted."""
template_dir = TEMPLATES_DIR / name
if not template_dir.exists():
raise FileNotFoundError(f"Template '{name}' not found")
versions = sorted(template_dir.glob("v*.yaml"))
if not versions:
raise FileNotFoundError(f"No versions found for '{name}'")
target = template_dir / f"v{version}.yaml" if version else versions[-1]
if not target.exists():
raise FileNotFoundError(f"Version {version} not found for '{name}'")
return PromptTemplate(target)
Step 4: Implement rendering logic
The PromptTemplate.render() method returns a provider-agnostic payload. Your application code stays clean:
# app/summarize.py
from render import load_template
def summarize(text: str) -> str:
template = load_template("summarize") # loads latest v1.2.0
payload = template.render({"content": text})
# payload now has model, messages[], parameters — send to any OpenAI-compatible endpoint
response = client.chat.completions.create(**payload)
return response.choices[0].message.content
Notice the version is implicit. Pin a version only when you need reproducibility for a specific workflow:
template = load_template("summarize", version="1.1.0") # pinned for batch job
Step 5: Add CI/CD validation
Every pull request should validate schema compliance, variable coverage, and render correctness. GitHub Actions example:
# .github/workflows/prompt-ci.yml
name: Prompt CI
on:
pull_request:
paths:
- "prompts/**"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install pyyaml jsonschema
- name: Validate all templates
run: |
python -c "
import yaml, json, sys
from pathlib import Path
from jsonschema import validate
schema = json.load(open('prompts/schemas/template.schema.json'))
errors = 0
for path in Path('prompts/templates').rglob('v*.yaml'):
try:
t = yaml.safe_load(open(path))
validate(t, schema)
# Check variable coverage
from string import Template
declared = {v['name'] for v in t['variables']}
for field in ('system', 'user'):
used = set(Template(t[field]).get_identifiers())
if undeclared := used - declared:
print(f'{path}: {field} uses undeclared {undeclared}')
errors += 1
except Exception as e:
print(f'{path}: {e}')
errors += 1
sys.exit(errors)
- name: Run render tests
run: pytest prompts/tests/test_templates.py -v
The test file exercises real renders with fixtures:
# prompts/tests/test_templates.py
import pytest
from render import load_template
SAMPLE_TEXT = Path(__file__).with_name("fixtures") / "summarize_sample.txt"
def test_summarize_renders():
template = load_template("summarize")
payload = template.render({"content": SAMPLE_TEXT.read_text()})
assert payload["model"] == "gpt-4o-mini"
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "system"
assert "{{content}}" not in payload["messages"][1]["content"]
assert "parameters" in payload
def test_summarize_rejects_missing_variable():
template = load_template("summarize")
with pytest.raises(ValueError, match="Missing required variable: content"):
template.render({})
def test_summarize_rejects_oversized_input():
template = load_template("summarize")
huge = "x" * 60000
with pytest.raises(ValueError, match="exceeds max_length"):
template.render({"content": huge})
Run pytest prompts/tests/ locally before pushing. The CI will catch anything you miss.
Step 6: Handle environment-specific overrides
Production often needs different models or parameters than staging. Don’t fork templates. Override at render time.
# render.py (add to PromptTemplate class)
def render_with_overrides(
self,
variables: dict[str, Any],
*,
model: str | None = None,
parameters: dict | None = None,
) -> dict[str, Any]:
payload = self.render(variables)
if model:
payload["model"] = model
if parameters:
payload["parameters"] = {**payload.get("parameters", {}), **parameters}
return payload
Usage in your application bootstrap:
# config/prompts.py
import os
from render import load_template
ENV = os.getenv("ENVIRONMENT", "development")
MODEL_OVERRIDES = {
"production": "gpt-4o",
"staging": "gpt-4o-mini",
"development": "gpt-4o-mini",
}
PARAM_OVERRIDES = {
"production": {"temperature": 0.1},
"staging": {"temperature": 0.2},
"development": {"temperature": 0.7},
}
def get_template(name: str, version: str | None = None):
template = load_template(name, version)
return lambda vars: template.render_with_overrides(
vars,
model=MODEL_OVERRIDES[ENV],
parameters=PARAM_OVERRIDES[ENV],
)
Now summarize = get_template("summarize") returns a callable that automatically uses the right model for the environment. No template duplication.
Step 7: Deploy and monitor
Version control prompt templates means you can trace every production response to a specific git commit. Add metadata to your inference logs:
# logging_middleware.py
import json
import uuid
from render import load_template
def log_inference(template_name: str, version: str, variables: dict, response: Any, latency_ms: int):
log_entry = {
"request_id": str(uuid.uuid4()),
"template": template_name,
"template_version": version,
"variables_hash": hash(frozenset(variables.items())), # PII-safe
"model": response.model,
"latency_ms": latency_ms,
"tokens_in": response.usage.prompt_tokens,
"tokens_out": response.usage.completion_tokens,
}
structured_logger.info(json.dumps(log_entry))
When a regression appears, you can query logs by template_version, correlate with the git tag, and roll back the template in seconds:
git revert <commit-that-bumped-version> # or
git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0 # if you tag versions
Verification checklist
Before merging a template change, confirm:
- Schema passes —
python -m jsonschema -i prompts/templates/summarize/v2.yaml prompts/schemas/template.schema.jsonexits 0 - Variables covered — No
{{undeclared}}in system or user fields - Tests green —
pytest prompts/tests/ -k summarizepasses - Render smoke test —
python -c "from render import load_template; print(load_template('summarize').render({'content': 'test'})['messages'][1]['content'])"outputs interpolated text - Version bumped — Semantic version in filename matches
versionfield (v1.2.0 → version: “1.2.0”) - Changelog updated — One line in
CHANGELOG.mddescribing the behavioral change
What this buys you
- Bisectable regressions:
git bisecton prompt changes works because each version is a commit - Safe rollbacks: Revert a single template without touching application code
- Audit trail: Every production request links to a template version and git SHA
- Cross-team reviews: Product can propose prompt changes via PR; engineering validates schema and tests
- Environment parity: Same template, different model params per environment — no drift
The pattern scales. We use this structure across 200+ templates at n4n.ai, and the CI catches type mismatches and missing variables before they reach staging. Start with one template, add the schema and renderer, then migrate the rest incrementally.