PHASE 05 · TEXT-TO-SQL

Talk to your database

Goal: Let users ask structured questions in plain English.

What this is

Text-to-SQL is the pattern where the LLM writes SQL from a natural-language question. It's the highest-leverage pattern for structured data — most internal 'BI tools' are just text-to-SQL with a chart on top.

The hard parts aren't writing the first prompt. They're schema grounding (telling the model which tables and columns exist), validation (making sure the generated SQL actually runs), and safety (read-only, no dropping tables, capped row counts).

The plan

  1. Pick a small SQLite DB — Chinook, Northwind, or your own 3–5 table dataset.
  2. Build the prompt: include the schema as CREATE TABLE statements, plus 2–3 few-shot Q/SQL examples and explicit constraints (read-only, LIMIT 100).
  3. Run the generated SQL. If it errors, send the error back to the model and ask for a fix — cap at one retry.
  4. Wrap the whole thing as a tool and plug it into your Phase 4 agent.

Ask your coding assistant

  • Show me a minimal text-to-SQL prompt with schema injection and few-shot examples.
  • How do I handle large schemas (50+ tables) that don't fit in the context window?
  • What safety guards should always wrap a text-to-SQL tool?
  • What does a SQL repair loop look like, and when should you stop retrying?

What you'll have when you're done

  • You can answer questions over structured data without writing SQL.
  • You understand schema injection — and why it gets hard once tables outnumber your context window.
  • You've felt the generate → validate → repair pattern that applies to any code-generating LLM use.
  • You have a SQL tool plugged into your agent — it'll become an MCP server in Phase 9.

Deliverable

text_to_sql.py — A schema-aware text-to-SQL tool with validation and a one-shot repair loop.

Concepts unlocked: schema injection · query validation · repair loop · structured outputs

Intuition

Your Phase 4 agent can already search documents, browse the web, and do math. But think about where most of a company's answers actually live. At Zentara Logistics — our made-up shipping company — the answer to "how many customers signed up in May?" isn't written in any document. It sits in a database: neat rows and tables of customers, orders, and payments. Your Phase 2 RAG is great with prose, but this answer isn't in any paragraph — it has to be counted.

Databases already have a precise language for exactly these questions: SQL. The only catch is that most people can't write it. So here's the idea — let the model write the SQL for you, from plain English.

Don't worry about reading that query — here it is, word for word: SELECT COUNT(*) means "count the rows", FROM customers means "in the customers table", and WHERE month = 'May' means "but only the rows where the month is May." One line of SQL, one plain-English sentence: count the customers who signed up in May. And you'll never have to write these yourself — writing them is the model's whole job in this phase.

What text-to-SQL is. Text-to-SQL: the model turns a plain-English question into a database query. Most internal BI tools (business intelligence — the dashboard apps full of charts that your finance team stares at all day) are exactly this: text-to-SQL with a chart drawn on top.

How it works

The flow is short. You give the model three things: the question, the schema — the database's table of contents: which tables exist and what columns each one has — and a few examples: two or three question→SQL pairs pasted into the prompt, so the model can see the pattern it should copy. The model writes a SQL query. Your code runs that query, hands the rows back, and the model turns them into a plain answer.

This is Phase 4's loop wearing a new hat. Look at who does what: the model asks (by writing SQL as text), the harness runs the query for real, and the result comes back as a new message. Same pattern as the calculator in Phase 4 — the LLM never touches the database itself. Text-to-SQL is just one more tool in your agent's toolbox.

One question, end to end

Let's watch one real question travel the whole pipeline. A Zentara manager asks: "Which three customers shipped the most packages in June?"

Notice who did what. The model wrote text twice — once as SQL, once as the friendly answer. The harness did all the actual running. And the number 1,204 came from the database, not from the model's memory — that's why it's exact instead of a plausible guess.

The three hard parts

That end-to-end run looked smooth. Writing the first query is easy — making it reliable comes down to three things:

  • Schema grounding — the model can't query tables it doesn't know about. You paste your table definitions (the CREATE TABLE lines) into the prompt so it knows exactly what exists.
  • Validation — generated SQL sometimes won't run (wrong column name, a typo). You actually run it and catch the error before trusting the result.
  • Safety — never let generated SQL change your data. Use a read-only connection, block anything that isn't a SELECT, and add a LIMIT so one query can't pull a million rows.

"Schema" has come up a few times now — here's what one actually looks like, so it stops being abstract. This is Zentara's orders table:

CREATE TABLE orders (
  id             INTEGER,   -- one row per shipment
  customer_name  TEXT,      -- who sent it
  ship_month     TEXT,      -- e.g. 'June'
  weight_kg      REAL       -- package weight
);

That's the whole trick: a table's name, its column names, and what type each column holds. Paste a few of these into the prompt and the model knows precisely what it's allowed to query — no more guessing whether the table is called orders or shipments.

Treat generated SQL as untrusted. A read-only connection, a row limit, and a SELECT-only rule are not optional — they're how you stop an LLM from accidentally wiping a table. Never run generated SQL with write access.
LIMIT has a quiet failure mode. A row limit protects you, but it's blunt: if a legitimate answer needed 500 rows and your cap is 100, the result gets silently truncated — the query "works," the answer looks fine, and nobody notices it's incomplete. A good build flags it whenever the row count comes back exactly at the limit, so you know to look closer.

The repair loop

What happens when the SQL doesn't run? You don't give up — you tell the model what broke. Send the database's error message back and ask it to fix the query. One retry catches most mistakes.

Concretely: you ask "how many users signed up?" and the model writes SELECT COUNT(*) FROM user. The database throws no such table: user — the table is actually called users. You send that exact error back with the query, and the model returns SELECT COUNT(*) FROM users. It runs. Fixed.

If this rhythm feels familiar, it should — it's Phase 4's agent loop again: the model asks, the harness runs, the result comes back as a new message. The only twist is that here the "result" is sometimes an error message — and that error is exactly the feedback the model needs to repair its own query. This "try → if it fails, feed the error back → try again" pattern is a repair loop, and you'll see it all over agent code. Cap it (one or two retries) so a truly broken query can't loop forever.

And when the cap runs out? Stop, and show the error honestly: "I couldn't answer this — the query failed with: no such column: signup_date." That feels like failure, but it's the right move. The one thing a data tool must never do is invent a plausible-looking number after the database refused to give a real one.

Fail honest, not confident. A capped repair loop has exactly two exits: a query that ran, or an error shown to the user as-is. There is no third exit where the model "just answers anyway" — that would turn your exact tool back into a guessing machine.

Build it

Build text_to_sql.py against a small sample database, then wrap it as a tool and plug it into the agent.py from Phase 4 — now your agent can answer from documents and from data.

Read what it builds — the prompt carries the schema, and a small loop handles errors safely, with an honest exit when the one allowed repair also fails:

# text_to_sql.py — question in, answer from your database out
import sqlite3
from anthropic import Anthropic
client = Anthropic()

SCHEMA = open("schema.sql").read()   # your CREATE TABLE statements

def to_sql(question):
    prompt = f"{SCHEMA}\n\nWrite ONE read-only SQL query (SELECT only) for:\n{question}"
    return client.messages.create(
        model="claude-sonnet-4-6", max_tokens=400,
        messages=[{"role": "user", "content": prompt}]).content[0].text.strip()

def ask(question, db="chinook.db"):
    sql = to_sql(question)
    if not sql.lower().startswith("select"):
        return "Refused: only read-only queries are allowed."
    con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)   # read-only connection
    try:
        return con.execute(sql + " LIMIT 100").fetchall()
    except sqlite3.Error as e:
        fixed = to_sql(f"{question}\n\nThat query failed with: {e}. Fix it.")  # repair, once
        try:
            return con.execute(fixed + " LIMIT 100").fetchall()
        except sqlite3.Error as e2:                          # cap reached — fail honest
            return f"Query failed after one repair attempt: {e2}"  # never invent data
Deliverable — text_to_sql.py. A schema-aware tool that writes SQL, runs it safely (read-only, limited), repairs itself once on error — and reports the error honestly if the repair fails too. Wired into your agent, it answers questions straight from your data.