"Let users ask the database questions in English" is the most-requested internal LLM feature and the easiest one to ship dangerously. The model will happily generate DELETE, join across tenants, or scan a billion rows — unless the system around it makes those outcomes impossible.
Tier: Advanced · Workflow area: Building LLM Features
This playbook layers defense in depth: a read-only database role (the real control), schema-scoped prompting, SQL parsing and validation before execution, result limits, and an eval set of known question→SQL pairs that gates every prompt change.
Do not point this at production OLTP or at customer-facing surfaces in v1 — start on a read replica or warehouse, internal users only.
pip install anthropic sqlglot psycopg fastapi.Create the real security boundary first — in the database.
CREATE ROLE llm_reader LOGIN PASSWORD '...' ;
GRANT CONNECT ON DATABASE analytics TO llm_reader;
GRANT USAGE ON SCHEMA reporting TO llm_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO llm_reader; -- and nothing else
ALTER ROLE llm_reader SET statement_timeout = '10s';
ALTER ROLE llm_reader SET work_mem = '64MB';
Views in the reporting schema are your allow-list: expose curated, pre-joined, PII-scrubbed views rather than raw tables. Everything after this step is UX and cost control; this step is security.
Scope the prompt to a curated schema card. Dumping pg_dump --schema-only wastes context and invites wrong joins. Hand-write (once) a schema card per exposed view: columns, types, meanings, join keys, and two example queries. Include today's date — "last month" questions fail without it.
Generate SQL as structured output.
import anthropic
from pydantic import BaseModel
class SQLResponse(BaseModel):
sql: str
explanation: str
confidence: float
client = anthropic.Anthropic(timeout=20.0)
def generate_sql(question: str) -> SQLResponse:
resp = client.messages.parse(
model="claude-opus-4-8", max_tokens=1024,
system=f"Generate one PostgreSQL SELECT for the question, using ONLY "
f"these views:\n{SCHEMA_CARD}\nToday is {date.today()}. "
f"If the question cannot be answered from these views, set "
f"sql to 'UNANSWERABLE'.",
messages=[{"role": "user", "content": question}],
output_format=SQLResponse)
return resp.parsed_output
Parse and validate before executing — never trust, always verify. sqlglot gives you a real AST:
import sqlglot
from sqlglot import exp
ALLOWED_TABLES = {"reporting.active_users", "reporting.orders", ...}
def validate(sql: str) -> str:
tree = sqlglot.parse_one(sql, read="postgres") # raises on invalid SQL
if not isinstance(tree, (exp.Select, exp.Union)):
raise ValueError("only SELECT allowed")
for node in tree.walk():
if isinstance(node, (exp.Insert, exp.Delete, exp.Update, exp.Drop,
exp.Alter, exp.Create, exp.Command)):
raise ValueError("mutating statement")
tables = {t.sql(dialect="postgres").lower() for t in tree.find_all(exp.Table)}
if not tables <= ALLOWED_TABLES:
raise ValueError(f"table not allow-listed: {tables - ALLOWED_TABLES}")
if not tree.args.get("limit"):
tree = tree.limit(1000) # enforce result cap
return tree.sql(dialect="postgres")
Execute with belt and suspenders, show your work. Run the validated SQL as llm_reader (statement timeout already set). Return rows plus the SQL itself in the UI — analysts catch wrong queries instantly when they can read them; hiding the SQL converts every subtle error into silent misinformation.
Gate changes on the eval set. For each of the 20 known pairs, run generation → validation → execution and compare result sets (not SQL strings — many correct spellings exist) against the hand-written query's results. Score = exact-result matches. Every prompt/schema-card/model change must not lower the score; wire it as a pytest job (pb-50 pattern).
The validator and the read-only role are independent layers on purpose. sqlglot can be bypassed by dialect edge cases; a leaked mutating query then dies at the role. Defense in depth means each layer assumes the previous one failed.
client.responses.parse(model="gpt-5.6", ..., text_format=SQLResponse).dataViewer only + maximum_bytes_billed; Snowflake — a reader role with a resource monitor. Same architecture, platform-native guardrails.UNANSWERABLE path fires for questions outside the exposed views instead of hallucinating joins.DELETE misses WITH d AS (DELETE ...), comments, and casing games. AST or nothing.t1.customer_ref) and maintain a local mapping used after generation.