A text-to-SQL system that demos well and one that survives contact with analysts differ in three places: schema retrieval (the model sees only relevant tables, with semantics), SQL validation before execution (parse, lint, allowlist), and a read-only sandbox with cost guards. This playbook builds all three around any warehouse.
Tier: Advanced · Workflow area: Analytics Automation & NL Interfaces
Do not build this over an undocumented schema with ambiguous column semantics — fix the semantic layer first, or the copilot will confidently query the wrong revenue column.
sqlglot for parsing/validation; the embedding stack from pb-17 for schema retrieval.Build the schema corpus. One document per table: DDL, column descriptions, enum values, 3 sample rows (synthetic if sensitive), join keys, and — critically — "gotcha" notes ("orders.amount is cents; use orders_v2 after 2024-03"). If you have dbt, generate these from model YAML docs.
Schema retrieval, not schema dumping. Embed the table documents; at question time retrieve top-k tables (k=5–8) plus their FK-linked neighbors. For 20+-table warehouses this beats stuffing the full DDL — both on accuracy (less distraction) and cost.
Generate with a structured contract. Ask for SQL plus machine-checkable metadata:
class SQLDraft(BaseModel):
sql: str
tables_used: list[str]
assumptions: list[str] # surfaced to the user, always
needs_clarification: bool # model may refuse to guess
clarifying_question: str | None
Few-shot with 5–8 pairs retrieved from your question→SQL history by similarity to the incoming question.
Validate before execution — the guard stack. Every draft passes through, in order:
import sqlglot
from sqlglot import exp
tree = sqlglot.parse_one(draft.sql, dialect="snowflake") # 1. parses at all
assert not list(tree.find_all(exp.Insert, exp.Update, exp.Delete, exp.Drop, exp.Create)) # 2. read-only
used = {t.name for t in tree.find_all(exp.Table)}
assert used <= ALLOWED_TABLES # 3. allowlist
if not tree.args.get("limit"): tree = tree.limit(10000) # 4. inject LIMIT
final_sql = tree.sql(dialect="snowflake")
Add an EXPLAIN dry-run and reject plans over a scan-bytes threshold on BigQuery/Snowflake-class warehouses.
Execute, then self-check the result. Run in the sandbox role; hand the result preview (schema + 5 rows + rowcount) back to the model: "Does this result plausibly answer the question? List anomalies." Empty results, suspicious 1-row aggregates, and unit mismatches get flagged to the user instead of presented as answers.
Evaluate on execution accuracy. Score the eval set on execution match (result equivalence against reference SQL), not string match — many correct SQL formulations differ textually. Track: execution-accuracy, guard-rejection rate, clarification rate, and p95 latency. Iterate on schema docs before iterating on prompts; missing column semantics cause most failures.
The model asking a clarifying question is a success mode, not a failure — reward needs_clarification in your eval scoring, or you will train yourself into a system that always guesses.
SQLDraft contract. A frontier-tier model (e.g. claude-opus-4-8) for generation + cheap tier for the result self-check is a sensible split.read="postgres", write="snowflake") if your few-shot history and target warehouse differ.EXECUTE IMMEDIATE, comments, and dialect quirks; only an AST check (sqlglot) counts as validation.SELECT * cross-join on a BI warehouse pays for the entire project's API budget; scan-bytes limits are non-negotiable.