The debate over prompts in code vs database is rarely about where text is stored; it’s about who controls change and how fast you can undo mistakes. Treat prompts as code unless you have a concrete reason to hand that control to a runtime store, and you’ll avoid most operational pain we’ve seen in production LLM systems.
The case for prompts in code
Keeping prompts in code means your prompt templates live in the same repo as the logic that calls the model. You get Git history, pull request review, and CI tests that fail when a template breaks variable substitution.
from jinja2 import Template
SYSTEM_PROMPT = Template(
"You are a {{ role }} assistant. Answer concisely. "
"Use {{ dialect }} spelling."
)
def build_messages(role: str, dialect: str, user_q: str):
return [
{"role": "system", "content": SYSTEM_PROMPT.render(role=role, dialect=dialect)},
{"role": "user", "content": user_q},
]
This is prompt-as-code. A reviewer sees the exact string in a diff. A unit test can assert that build_messages raises on missing variables or that the rendered output contains expected guards.
def test_build_messages_renders():
msgs = build_messages("sales", "British", "Hi")
assert "British" in msgs[0]["content"]
assert msgs[1]["role"] == "user"
Rollback is git revert. No separate migration, no cache invalidation, no admin UI to secure. Static analyzers can scan the repo for leaked secrets or banned phrases. When you use an inference gateway, the source of the prompt is irrelevant to routing. n4n.ai forwards provider cache-control hints whether you hardcoded the prompt or fetched it from Postgres, so caching behavior is orthogonal to this decision.
The case for prompts in database
A database becomes attractive when non-engineers need to edit copy, or when you want to swap prompts per request without a deploy. Marketing wants to tweak the tone for a campaign; a DB row changes, and the next call picks it up.
import psycopg2
def get_prompt(conn, prompt_id: str) -> str:
with conn.cursor() as cur:
cur.execute("SELECT body FROM prompts WHERE id = %s", (prompt_id,))
return cur.fetchone()[0]
The win is latency of change, not latency of serving. You trade Git auditability for a UI form. That trade is valid for high-churn, low-risk prompts like a greeting message or a seasonal disclaimer.
Multi-tenant SaaS products push this further. Each tenant may need a distinct system prompt for brand voice:
def get_tenant_prompt(conn, tenant_id: str) -> str:
with conn.cursor() as cur:
cur.execute(
"SELECT body FROM tenant_prompts WHERE tenant_id = %s",
(tenant_id,)
)
row = cur.fetchone()
return row[0] if row else DEFAULT_TENANT_PROMPT
Now you can personalize without shipping code. But you own schema evolution. If you add a {{ city }} variable, old rows break unless you backfill. You need an admin panel with auth, and you need to log who changed what.
What you lose when leaving Git
Git gives you a free audit trail and bisect. A database gives you none of that unless you build it. We’ve seen teams lose a week to a prompt edit that silently dropped JSON mode because someone deleted a sentence in a textarea.
Concrete gaps:
- No diff review: a PM edits in a form, no one sees the before/after.
- No staged rollout: you can’t branch a prompt in the DB as easily as a Git branch.
- Secret leakage: prompts sometimes contain few-shot examples with PII; a DB backup spreads them wider than a private repo.
- Rollback complexity: reverting a DB prompt requires a down migration or a manual undo, not atomic with code deploy.
If you go to DB, you must add a version column and a created_by column at minimum.
CREATE TABLE prompts (
id text PRIMARY KEY,
body text NOT NULL,
version int NOT NULL,
created_by text NOT NULL,
updated_at timestamptz DEFAULT now()
);
Hybrid patterns that actually work
The pragmatic answer to prompts in code vs database is “both, with a clear ownership boundary.” Ship a default prompt in code. Allow a DB override keyed by prompt ID and environment.
DEFAULT_PROMPTS = {"support": "You are a support agent..."}
def resolve_prompt(conn, key: str) -> str:
row = get_override(conn, key)
return row if row else DEFAULT_PROMPTS[key]
This keeps 90% of prompts in Git, where they belong, and isolates the 10% that need runtime tweaking. You can also store only metadata (temperature, model, active experiment) in DB and keep the template in code.
For experimentation, store variant IDs in the DB and map them to code-defined templates:
{
"experiment": "summer_campaign",
"variant_a": "template:support_v1",
"variant_b": "template:support_v2"
}
The DB decides which variant a user gets; the code guarantees the template renders without syntax errors. This limits blast radius.
Performance and latency reality
A DB lookup adds 1–5 ms inside your VPC. That’s negligible compared to LLM latency (hundreds of ms to seconds). The real cost is operational: connection pooling, cache invalidation, and stale reads.
Cache the prompt row in a local LRU or Redis with a 60-second TTL. On write, bump a version and invalidate. Don’t query the DB on every token.
from functools import lru_cache
@lru_cache(maxsize=128)
def cached_prompt(prompt_id: str, version: int) -> str:
# version bumps on DB write
return fetch_from_db(prompt_id)
Provider-side caching (e.g., Anthropic’s prompt caching) works on the request payload. If you send the same prompt prefix repeatedly, the provider caches it. That works whether the prefix came from code or DB.
Security and compliance
Moving prompts to a database expands your compliance surface. A repo can be private, scanned in CI, and access-controlled via GitHub. A database requires network policies, connection encryption, and audit logging. If prompts contain regulated data, store them in a vault-backed table with row-level security, not a plain text column.
Code-side prompts can be reviewed by security tooling as part of the normal pipeline. DB-side prompts need equivalent tooling bolted on after the fact.
Decision matrix
Use this filter:
- Are prompts stable and reviewed by engineers? → Code.
- Do non-engineers change them weekly? → DB with versioning.
- Need per-tenant customization? → DB override keyed by tenant.
- Need A/B test without deploy? → DB variant map to code templates.
- Must satisfy strict audit with rollback? → Code unless DB has full change log.
If you can’t answer, default to code. The cost of extracting a prompt later is low; the cost of rebuilding audit trails after a DB mistake is high.
Takeaway
Start with prompts in code. It gives you diffs, tests, and rollback on day one. Move individual prompts to a database only when a specific workflow—non-engineer edits, runtime experiments, tenant overrides—demands it, and wrap that DB in version columns, auth, and caching. The prompts in code vs database question isn’t ideological; it’s about matching change control to the people who need to change things.