n4nAI

How to version control your prompt templates

Learn how to version control prompt templates with git, schema validation, CI/CD checks, and environment-specific overrides for production LLM systems.

n4n Team2 min read535 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. Schema passespython -m jsonschema -i prompts/templates/summarize/v2.yaml prompts/schemas/template.schema.json exits 0
  2. Variables covered — No {{undeclared}} in system or user fields
  3. Tests greenpytest prompts/tests/ -k summarize passes
  4. Render smoke testpython -c "from render import load_template; print(load_template('summarize').render({'content': 'test'})['messages'][1]['content'])" outputs interpolated text
  5. Version bumped — Semantic version in filename matches version field (v1.2.0 → version: “1.2.0”)
  6. Changelog updated — One line in CHANGELOG.md describing the behavioral change

What this buys you

  • Bisectable regressions: git bisect on 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.

Tagsprompt-templatesproduction-aiversion-control

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All prompt templates & variables posts →