n4nAI

How to build reusable prompt templates for your app

Learn to build reusable prompt templates with versioning, validation, and variable interpolation for production LLM applications.

n4n Team5 min read1,144 words

Audio narration

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

Reusable prompt templates turn brittle string concatenation into a maintainable, testable layer between your application logic and the model. They let you version prompts like code, validate inputs at runtime, and swap templates without redeploying. This guide walks through building a template system you can drop into any Python LLM application.

Step 1: Define the template schema

Start with a minimal schema that captures what a template needs: a unique identifier, version, the prompt text with placeholders, and metadata describing required variables. Store this as JSON or YAML so it lives alongside your code and can be reviewed in pull requests.

{
  "id": "summarize-article",
  "version": "1.2.0",
  "description": "Summarize a news article in 3 bullet points",
  "template": "Summarize the following article in exactly 3 bullet points. Focus on the main claim, key evidence, and implications.\n\nArticle:\n{{article_text}}\n\nSummary:",
  "variables": [
    {"name": "article_text", "type": "string", "required": true, "max_length": 15000}
  ],
  "model_hints": {
    "temperature": 0.2,
    "max_tokens": 200
  }
}

The variables array is the contract. It tells callers what to provide and lets you validate before the request leaves your process. The model_hints field captures parameters that belong with the prompt — temperature, top-p, stop sequences — so they travel with the template instead of scattering across call sites.

Verify: Load the file and confirm json.loads() or yaml.safe_load() parses without error. Every required variable appears in the template body wrapped in {{ }}.

Step 2: Build a template registry

A registry loads templates from disk, caches them, and exposes a simple lookup API. Keep it stateless and thread-safe so it works in web workers and background jobs alike.

# prompt_registry.py
from __future__ import annotations
import json
import threading
from pathlib import Path
from dataclasses import dataclass, field
from typing import Any

@dataclass(frozen=True)
class PromptTemplate:
    id: str
    version: str
    template: str
    variables: list[dict[str, Any]]
    model_hints: dict[str, Any] = field(default_factory=dict)

    def required_vars(self) -> set[str]:
        return {v["name"] for v in self.variables if v.get("required", True)}

class TemplateRegistry:
    def __init__(self, template_dir: Path):
        self._template_dir = template_dir
        self._cache: dict[str, PromptTemplate] = {}
        self._lock = threading.RLock()

    def get(self, template_id: str, version: str | None = None) -> PromptTemplate:
        key = f"{template_id}:{version or 'latest'}"
        with self._lock:
            if key in self._cache:
                return self._cache[key]

        # Resolve version: latest means highest semver in directory
        candidates = list(self._template_dir.glob(f"{template_id}.v*.json"))
        if not candidates:
            raise KeyError(f"Template not found: {template_id}")

        if version is None:
            # Pick highest semver
            def semver_key(p: Path) -> tuple[int, ...]:
                ver = p.stem.split(".v")[-1]
                return tuple(map(int, ver.split(".")))
            chosen = max(candidates, key=semver_key)
        else:
            chosen = next((p for p in candidates if f".v{version}." in p.name), None)
            if chosen is None:
                raise KeyError(f"Version not found: {template_id}@{version}")

        with chosen.open() as f:
            data = json.load(f)

        template = PromptTemplate(**data)
        with self._lock:
            self._cache[key] = template
        return template

    def list_templates(self) -> list[tuple[str, str]]:
        """Return (id, latest_version) pairs."""
        seen: dict[str, tuple[int, ...]] = {}
        for p in self._template_dir.glob("*.v*.json"):
            parts = p.stem.split(".v")
            tid, ver_str = parts[0], parts[1]
            ver = tuple(map(int, ver_str.split(".")))
            if tid not in seen or ver > seen[tid]:
                seen[tid] = ver
        return [(tid, ".".join(map(str, ver))) for tid, ver in seen.items()]

Verify: Instantiate TemplateRegistry(Path("prompts")) and call get("summarize-article"). Confirm the returned object has the expected id, version, and required_vars().

Step 3: Implement safe variable interpolation

Never use str.format() or f-strings directly on user input — they allow attribute access and format specifiers that can leak data or crash. Use a restricted interpolator that only substitutes known keys and escapes everything else.

# interpolation.py
from __future__ import annotations
import re
from typing import Any

_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")

class InterpolationError(ValueError):
    pass

def interpolate(template: str, variables: dict[str, Any], strict: bool = True) -> str:
    """
    Replace {{var}} placeholders with values from variables dict.
    If strict=True, raise on missing or extra variables.
    """
    provided = set(variables.keys())
    found = set(_PLACEHOLDER_RE.findall(template))

    missing = found - provided
    if missing and strict:
        raise InterpolationError(f"Missing variables: {sorted(missing)}")

    extra = provided - found
    if extra and strict:
        raise InterpolationError(f"Extra variables not used in template: {sorted(extra)}")

    def repl(match: re.Match) -> str:
        key = match.group(1)
        value = variables.get(key, "")
        # Coerce to string, escape newlines for log safety
        return str(value).replace("\n", "\\n")

    return _PLACEHOLDER_RE.sub(repl, template)

This function does three things: validates the variable set against the template, coerces values to strings, and sanitizes newlines so rendered prompts stay single-line in logs. The strict flag lets you relax validation for templates that intentionally accept optional context.

Verify: Run these cases in a REPL:

interpolate("Hello {{name}}", {"name": "Ada"})  # "Hello Ada"
interpolate("Hello {{name}}", {"name": "Ada", "extra": "ignored"}, strict=False)  # "Hello Ada"
interpolate("Hello {{name}}", {}, strict=True)  # raises InterpolationError

Step 4: Add runtime validation with JSON Schema

Variable declarations in the template schema are a contract. Enforce it at render time with JSON Schema so bad inputs fail fast with actionable messages.

# validation.py
from __future__ import annotations
from jsonschema import Draft202012Validator, ValidationError
from prompt_registry import PromptTemplate

def build_variable_schema(template: PromptTemplate) -> dict:
    properties = {}
    required = []
    for var in template.variables:
        name = var["name"]
        schema = {"type": var.get("type", "string")}
        if "min_length" in var:
            schema["minLength"] = var["min_length"]
        if "max_length" in var:
            schema["maxLength"] = var["max_length"]
        if "enum" in var:
            schema["enum"] = var["enum"]
        if "pattern" in var:
            schema["pattern"] = var["pattern"]
        properties[name] = schema
        if var.get("required", True):
            required.append(name)
    return {"type": "object", "properties": properties, "required": required, "additionalProperties": False}

_validator_cache: dict[str, Draft202012Validator] = {}

def validate_variables(template: PromptTemplate, variables: dict) -> None:
    key = f"{template.id}:{template.version}"
    if key not in _validator_cache:
        schema = build_variable_schema(template)
        _validator_cache[key] = Draft202012Validator(schema)
    _validator_cache[key].validate(variables)

The additionalProperties: False catches typos in variable names — a common source of silent failures where a template renders with empty placeholders because the caller passed article_txt instead of article_text.

Verify: Pass a dict missing a required field and confirm ValidationError is raised with a message naming the missing field. Pass an extra field and confirm it fails.

Step 5: Compose the render pipeline

Wire the registry, validator, and interpolator into a single render_prompt() function that your application calls. This is the only public API your callers need.

# renderer.py
from __future__ import annotations
from dataclasses import dataclass
from prompt_registry import TemplateRegistry, PromptTemplate
from interpolation import interpolate, InterpolationError
from validation import validate_variables

@dataclass(frozen=True)
class RenderedPrompt:
    template_id: str
    template_version: str
    messages: list[dict[str, str]]  # OpenAI chat format
    model_hints: dict[str, Any]

class PromptRenderer:
    def __init__(self, registry: TemplateRegistry):
        self._registry = registry

    def render(
        self,
        template_id: str,
        variables: dict[str, Any],
        version: str | None = None,
        system_prompt: str | None = None,
    ) -> RenderedPrompt:
        template = self._registry.get(template_id, version)
        validate_variables(template, variables)
        rendered = interpolate(template.template, variables)

        messages = []
        if system_prompt:
            messages.append({"role": "system", "content": system_prompt})
        messages.append({"role": "user", "content": rendered})

        return RenderedPrompt(
            template_id=template.id,
            template_version=template.version,
            messages=messages,
            model_hints=template.model_hints,
        )

The RenderedPrompt dataclass carries everything the LLM client needs: the chat-formatted messages, the exact template version used (critical for debugging), and model parameters. The optional system_prompt argument lets callers inject a shared system message without baking it into every template.

Verify: Call renderer.render("summarize-article", {"article_text": "..."}) and assert the result contains messages[1]["content"] with the article text substituted, no {{ }} remaining, and template_version matching the registry.

Step 6: Integrate with your LLM client

Keep the renderer decoupled from the HTTP client. Your call site assembles the request, sends it, and handles retries or fallbacks. This separation lets you swap providers or add middleware (logging, caching, rate limiting) without touching prompt logic.

# llm_client.py
from __future__ import annotations
import os
import httpx
from renderer import PromptRenderer, RenderedPrompt
from prompt_registry import TemplateRegistry

class LLMClient:
    def __init__(
        self,
        renderer: PromptRenderer,
        base_url: str = "https://api.openai.com/v1",
        api_key: str | None = None,
        timeout: float = 30.0,
    ):
        self._renderer = renderer
        self._client = httpx.Client(
            base_url=base_url,
            headers={"Authorization": f"Bearer {api_key or os.getenv('OPENAI_API_KEY')}"},
            timeout=timeout,
        )

    def complete(
        self,
        template_id: str,
        variables: dict[str, Any],
        version: str | None = None,
        system_prompt: str | None = None,
        **overrides,
    ) -> str:
        rendered: RenderedPrompt = self._renderer.render(
            template_id, variables, version, system_prompt
        )

        payload = {
            "model": overrides.pop("model", "gpt-4o-mini"),
            "messages": rendered.messages,
            **rendered.model_hints,
            **overrides,
        }

        resp = self._client.post("/chat/completions", json=payload)
        resp.raise_for_status()
        data = resp.json()
        return data["choices"][0]["message"]["content"]

The **overrides parameter lets callers override model hints per-request (e.g., higher max_tokens for a specific call) without mutating the template. The renderer returns the resolved model_hints so the client can merge them predictably: template defaults first, caller overrides second.

Verify: Call client.complete("summarize-article", {"article_text": "test article"}) and confirm a non-empty string returns. Check that the request payload sent to the API includes the correct messages array and merged parameters.

Step 7: Version templates like code

Treat template files as source code. Store them in prompts/ at the repo root, one file per template per version. Use semantic versioning in the filename: summarize-article.v1.2.0.json. Never overwrite an existing version — publish a new file and update the registry’s “latest” pointer by adding the new file.

prompts/
├── summarize-article.v1.0.0.json
├── summarize-article.v1.1.0.json
├── summarize-article.v1.2.0.json
├── extract-entities.v1.0.0.json
└── classify-intent.v1.0.0.json

Add a CI step that validates every template file on push:

# .github/workflows/validate-prompts.yml
name: Validate prompt templates
on: [push, pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.11"}
      - run: pip install jsonschema pyyaml
      - run: python -m scripts.validate_all_prompts

The validation script loads each file, runs build_variable_schema(), and confirms the template string only references declared variables.

Verify: Create a malformed template (missing variable in schema, extra placeholder in template) and confirm CI fails with a clear error.

Step 8: Add observability

Log the template ID and version on every request. This lets you trace production issues to a specific prompt version and measure performance per template.

# logging_middleware.py
from __future__ import annotations
import logging
import time
from llm_client import LLMClient

logger = logging.getLogger("llm_calls")

class ObservedLLMClient:
    def __init__(self, inner: LLMClient):
        self._inner = inner

    def complete(self, *args, **kwargs) -> str:
        template_id = args[0] if args else kwargs.get("template_id", "unknown")
        start = time.perf_counter()
        try:
            result = self._inner.complete(*args, **kwargs)
            duration_ms = (time.perf_counter() - start) * 1000
            logger.info(
                "llm_complete",
                extra={
                    "template_id": template_id,
                    "duration_ms": round(duration_ms, 2),
                    "status": "success",
                },
            )
            return result
        except Exception as e:
            duration_ms = (time.perf_counter() - start) * 1000
            logger.exception(
                "llm_complete_failed",
                extra={
                    "template_id": template_id,
                    "duration_ms": round(duration_ms, 2),
                    "status": "error",
                    "error_type": type(e).__name__,
                },
            )
            raise

Structured logging with extra fields makes it trivial to query in Datadog, Splunk, or CloudWatch. Include template_version in the log context by pulling it from the RenderedPrompt if you need per-version latency percentiles.

Verify: Tail logs while making a request. Confirm a line appears with template_id, duration_ms, and status: success.

Step 9: Write contract tests for each template

Every template should have a test that renders it with valid inputs and asserts the output structure. This catches regressions when you update a template or the interpolation logic.

# tests/test_summarize_article.py
import pytest
from renderer import PromptRenderer
from prompt_registry import TemplateRegistry

@pytest.fixture
def renderer(tmp_path):
    # Copy the real template file to a temp dir for isolation
    prompt_dir = tmp_path / "prompts"
    prompt_dir.mkdir()
    (prompt_dir / "summarize-article.v1.2.0.json").write_text("""{
        "id": "summarize-article",
        "version": "1.2.0",
        "description": "Summarize a news article in 3 bullet points",
        "template": "Summarize the following article in exactly 3 bullet points. Focus on the main claim, key evidence, and implications.\\n\\nArticle:\\n{{article_text}}\\n\\nSummary:",
        "variables": [{"name": "article_text", "type": "string", "required": true, "max_length": 15000}],
        "model_hints": {"temperature": 0.2, "max_tokens": 200}
    }""")
    return PromptRenderer(TemplateRegistry(prompt_dir))

def test_summarize_article_renders(renderer):
    article = "Scientists discovered a new species of frog in the Amazon. It glows in the dark."
    rendered = renderer.render("summarize-article", {"article_text": article})

    assert rendered.template_id == "summarize-article"
    assert rendered.template_version == "1.2.0"
    assert len(rendered.messages) == 1
    assert rendered.messages[0]["role"] == "user"
    assert article in rendered.messages[0]["content"]
    assert "{{article_text}}" not in rendered.messages[0]["content"]
    assert rendered.model_hints["temperature"] == 0.2

def test_summarize_article_rejects_missing_var(renderer):
    with pytest.raises(Exception):  # ValidationError or InterpolationError
        renderer.render("summarize-article", {})

Run these in CI on every push. They’re fast, deterministic, and catch the most common failure modes: typos in variable names, schema drift, and interpolation bugs.

Verify: Run pytest tests/test_summarize_article.py -v and confirm both tests pass.

Step 10: Handle multi-turn templates

Some prompts need conversation history. Extend the schema with a message_templates array instead of a single template string, where each entry has a role and content with placeholders.

{
  "id": "code-review",
  "version": "1.0.0",
  "description": "Review a pull request with context",
  "message_templates": [
    {"role": "system", "content": "You are a senior engineer reviewing a PR. Be concise and actionable."},
    {"role": "user", "content": "Repository context:\n{{repo_context}}\n\nPR diff:\n{{pr_diff}}\n\nReview:"}
  ],
  "variables": [
    {"name": "repo_context", "type": "string", "required": true},
    {"name": "pr_diff", "type": "string", "required": true, "max_length": 50000}
  ]
}

Update the renderer to handle both formats:

def _render_messages(self, template: PromptTemplate, variables: dict) -> list[dict[str, str]]:
    if hasattr(template, "message_templates") and template.message_templates:
        messages = []
        for mt in template.message_templates:
            content = interpolate(mt["content"], variables)
            messages.append({"role": mt["role"], "content": content})
        return messages
    # Legacy single-template format
    rendered = interpolate(template.template, variables)
    return [{"role": "user", "content": rendered}]

Verify: Render a multi-turn template and assert the returned messages list has the correct roles in order, with all placeholders substituted.

Verification checklist

Before shipping, run through this list manually or automate it in a smoke test script:

  1. Registry loadsregistry.list_templates() returns expected IDs and versions.
  2. Render succeedsrenderer.render() returns RenderedPrompt with no {{ }} in content.
  3. Validation catches missing vars — calling with empty dict raises.
  4. Validation catches extra vars — calling with unknown key raises (strict mode).
  5. Version pinning worksrenderer.render("id", vars, version="1.0.0") returns that version.
  6. Model hints merge — request payload includes template defaults overridden by caller args.
  7. Logs contain template_id and version — structured log line appears on success and error.
  8. CI validates all templatespython -m scripts.validate_all_prompts exits 0.
  9. Contract tests passpytest tests/ exits 0.
  10. Multi-turn renders correctly — roles and content order match schema.

What this buys you

  • Deploy-time safety: Bad templates fail in CI, not production.
  • Runtime safety: Invalid inputs raise before the HTTP request.
  • Auditability: Every completion carries the exact template version that produced it.
  • Iteration speed: Change a prompt by adding a file — no code deploy needed if the variable contract stays the same.
  • Portability: The renderer knows nothing about the LLM provider. Swap OpenAI for Anthropic or a local model by changing the client, not the prompt layer.

If you’re running multiple models across providers and want a single endpoint that handles fallback, caching, and usage metering without rewriting your client, n4n.ai exposes an OpenAI-compatible API that forwards model_hints and respects routing directives — but the template layer above works the same regardless of where the request lands.

Tagsprompt-templatesllm-appsprompt-engineering

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 →