AI EngineeringZero to ProductionHome·About·Contact
Project 5 · Design Chapter

Data Analyst Agent (Text-to-SQL)

"Talk to your database." A non-technical user asks a question in plain English; the agent writes SQL against your schema, runs it read-only, and explains the result in words (and a chart). A top enterprise ask in 2026 — and a superb showcase of grounding, tool use, and a strict safety boundary.

🎯 Advanced🔥 hot in enterprise📊 data / BI teamstext-to-SQL

What this project teaches you to design

  • Grounding the model in a database schema so it writes correct SQL.
  • The read-only boundary that makes running generated SQL safe.
  • A generate → validate → run → explain loop that self-corrects on errors.
  • Text-to-SQL evals: does the query return the right answer?

The brief advanced

"Let anyone answer their own data questions without waiting on the data team." Every "can you pull the numbers for…" is a ticket to a busy analyst. An agent that turns the question into correct SQL, runs it safely, and explains the answer democratizes data — while the analysts focus on the hard modeling work.

1 · Discovery — where does the time go? advanced

Where time goesAgent leverage
Analysts writing ad-hoc SQL for others' questions⭐⭐⭐ high — the core value
Non-technical users blocked waiting for numbers⭐⭐⭐ high — self-serve
Remembering table/column names & joins⭐⭐ medium — schema grounding
Deep statistical / causal analysis, data modeling⭐ low — humans; agent does the retrieval
Problem statement"Business users constantly ask the data team for numbers that are a straightforward query. If an agent translated the question to correct SQL, ran it read-only, and explained the result, users could self-serve the routine questions — freeing analysts, with zero risk of the agent modifying data."

2 · Architecture advanced

QuestionEnglish schema + examplesRAG grounding Analyst agentwrite SQL validate SQL run (read-only) error?retry / fix Explainwords + chart
🗺️ How to read this diagram

This picture is the whole agent as a pipeline: an English question flows left to right, becomes SQL, is checked and run safely, and comes back as a plain-English answer. Follow the blue arrows.

  • Question (English), far left, is what the user types. It feeds the Analyst agent box in the middle, whose job is to write SQL.
  • Above the agent, schema + examples (RAG grounding) feeds in the table/column definitions and example queries — this is what stops the model from inventing column names.
  • The agent's SQL goes to validate SQL (the read-only check) and then run (read-only) against the database — the two safety controls from Step 2.
  • The error? / retry-fix box is the self-correcting loop: the pink dashed arrow curving back to 'validate SQL' shows an error being fed back so the agent can try again.
  • Finally Explain (words + chart), far right, turns the result rows into a sentence (and optionally a chart) for the user.

In short: Left-to-right is the happy path (question → SQL → validate → run → explain); the one dashed arrow looping backward is the agent fixing its own mistakes.

A Chapter 4 loop: grounded in the DB schema (RAG), the agent writes SQL, a validator checks it's read-only and well-formed, it runs against a read-only connection, and — critically — if the query errors, the error goes back to the agent to self-correct. Then it explains the result.

3 · Risk & safety model advanced

Running model-generated SQL against a real database sounds scary — the read-only boundary is what makes it safe, and it's enforced in infrastructure, not the prompt.

RiskControl
🔴 Destructive SQL (DROP/DELETE/UPDATE)Connect with a read-only database user — writes are impossible at the DB level. Also validate the query is a single SELECT before running. Belt + suspenders.
🔴 Reading data the user shouldn't seeThe read-only user only has access to permitted tables/rows; enforce row-level security or per-user views (never trust the model)
🟠 Runaway / expensive queryStatement timeout + auto LIMIT; run against a replica, not the primary
🟠 Wrong-but-plausible answerShow the generated SQL to the user for transparency; verify with evals; explain assumptions
🟠 SQL injection via the questionThe model writes parameterized/validated SQL; the user's text is never concatenated into a query blindly
The golden rule of text-to-SQLThe safety boundary is a read-only DB credential on a replica, not "I told the model not to write." Even a perfect prompt can be tricked; a read-only user cannot execute a DELETE. This is the exact "guardrails in IAM, not the prompt" principle from the DevOps capstone.

4 · Tool surface advanced

ToolDoesRisk
get_schemaReturn table/column definitions (+ descriptions)🟢 read-only
sample_rowsA few example rows so the model sees real values/formats🟢 read-only
validate_sqlParse; confirm it's a single read-only SELECT; reject otherwise🟢 deterministic
run_queryExecute against the read-only replica, with timeout + LIMIT🟡 read-only exec (bounded)
make_chartRender results as a chart (e.g. via code execution / matplotlib)🟢 sandboxed
The self-correction loop is the magicWhen run_query returns a SQL error ("column x does not exist"), feed that error back as a tool result. The agent reads it, fixes the query, and retries — often getting it right on the second try. This is the Ch 4 agentic loop doing real work, and it dramatically improves accuracy.

5 · Schema grounding — the accuracy lever advanced

Text-to-SQL lives or dies on the model knowing your schema. Ground it (RAG, Ch 3) with:

6 · Evaluation expert

EvalMeasures
Execution accuracyDoes the generated query return the same result as a hand-written gold query? (the key metric — compare result sets, not SQL text)
Valid-SQL rate% of queries that run without error (after self-correction)
Read-only compliance (deterministic)The agent never produces a non-SELECT that passes the validator — hard-fail
Explanation faithfulnessDoes the plain-English answer match what the query actually returned? (LLM-judge)
The hard-fail eval here"Did any generated statement that reached run_query modify data or escape read-only?" Must be zero — enforced by the read-only credential and the validator, and tested. Same shape as the DevOps agent's "never exceed your rung" eval.

7 · Phased rollout expert

Phase 1 · SQL + result, for analysts — shows the generated SQL and the result; analysts sanity-check. Builds trust + a golden set. (Ch 3, 4)
Phase 2 · Self-serve for vetted question types — non-technical users on question patterns evals show are reliable; always shows the SQL. (Ch 5 decides which)
Phase 3 · Charts & dashboards — richer output, saved queries, scheduled reports. (Ch 6)
Never — write to the database, run on the primary, or hide the SQL. Read-only, on a replica, transparent.

Skills & course map expert

SkillLearn it in
Schema grounding / relevant-table retrievalCh 3
Generate→run→self-correct loop with toolsCh 4
Read-only boundary (creds + validator)Ch 8 safety model + Ch 6
Execution-accuracy evalsCh 5
Charts via sandboxed code executionCh 6 / tool concepts
Robust result handling & explanationPython P2 (data), P1 (formatting)

Build-along plan expert

  1. Set up a read-only SQLite/Postgres with a sample schema (e.g. orders/customers) — and a read-only user.
  2. Ground the model (Ch 3): a get_schema tool + a few example question→SQL pairs.
  3. The loop (Ch 4): agent writes SQL → validate_sql (must be a single SELECT) → run_query → on error, feed it back to self-correct → explain.
  4. Read-only hard boundary: connect as a read-only user; assert the validator rejects any non-SELECT (the hard-fail eval).
  5. Evals (Ch 5): a set of question + gold-SQL pairs; compare result sets for execution accuracy.
  6. Polish (Ch 6): add make_chart, timeouts/LIMIT, and always surface the SQL.
Great portfolio piece"Ask my database in English" demos beautifully and shows off grounding + the self-correcting loop + a real safety boundary. Want full build labs + runnable code (with a sample DB)? Ask and I'll build them like the DevOps capstone.
🛠️ Hands-on build — everything below is on this pageThe rest of this page is the complete, self-contained build: set up from an empty folder, paste in every file, run it (with a mock, so no API key is needed), and pass the tests. Follow it top to bottom — no other page required.

Learning objectives

  • Ground the model in a DB schema so it writes correct SQL.
  • Enforce read-only two ways: a SELECT-only validator + a read-only connection.
  • Build a loop that feeds SQL errors back so the model self-corrects.
  • Measure execution accuracy against gold queries.

What you'll build expert

Ask "how many US customers?" and get the answer — the agent writes SQL, runs it read-only, self-corrects on errors, and explains the result. Running model-generated SQL is safe because writes are impossible at the connection level.

Finished code includedAll in llm-course-starter/data-analyst/. Design rationale in the design chapter.

Step 1 · A tiny real database expert

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1

A local SQLite DB with customers and orders. get_schema() returns the CREATE statements to ground the model.

agent/db.pySCHEMA = """
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, country TEXT, plan TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL,
                     status TEXT, created TEXT);
"""
def init_db(): ...      # create + seed sample rows
def get_schema(): ...   # return the CREATE TABLE text for grounding
terminalpython agent/db.py       # builds the db, no API key needed
▶ How this works

Before the agent can write SQL it needs to know what tables and columns exist. This file builds a tiny local SQLite database and exposes its shape so we can hand that shape to the model. No API key is needed to run it.

  1. SCHEMA is just a block of text holding two CREATE TABLE statements — customers and orders. It describes the database's structure in plain SQL.
  2. init_db() creates those tables and fills them with a few sample rows, so there is real data to query.
  3. get_schema() returns that CREATE TABLE text. This is the grounding step: we paste this into the model's prompt so it knows the exact table and column names and never has to guess them.

What the output means: Running it builds the database file on disk. Nothing is printed to you here — it just prepares the data the later steps will query.

Try this: Open the database and add a third table (say products) to SCHEMA. Because get_schema() returns whatever is in SCHEMA, the model would immediately be able to write queries against the new table too.

Step 2 · The read-only guard (the safety boundary) expert

Step 2
Setup to run this snippet
_DB_PATH = 5           # demo constant (a hard cap)
class _re_t:
    match = 'demo'
    search = 'demo'
    def match(self, *a, **k): return 'demo'
    def search(self, *a, **k): return 'demo'
    def __getattr__(self, k): return 'demo'
re = _re_t()
import sqlite3
agent/db.pydef validate_sql(sql):
    s = sql.strip().rstrip(";").strip()
    if ";" in s: return False, "multiple statements not allowed"
    if not re.match(r"(?is)^\s*select\b", s): return False, "SELECT only"
    if re.search(r"(?i)\b(insert|update|delete|drop|alter|create|pragma)\b", s):
        return False, "write/DDL not allowed"
    return True, "ok"

def run_query(sql, limit=100):
    ok, reason = validate_sql(sql)
    if not ok: raise ValueError(f"blocked: {reason}")
    con = sqlite3.connect(f"file:{_DB_PATH}?mode=ro", uri=True)   # read-only!
    ...
▶ How this works

This is the safety boundary — the single most important code in the project. It decides whether a piece of SQL is allowed to run at all. The rule: only a single, read-only SELECT gets through; everything else is refused before it touches the database.

  1. validate_sql(sql) first cleans up the text (strips spaces and a trailing ;). If a semicolon still remains inside, there are two statements glued together — a classic injection trick — so it returns False with a reason. Every check returns a (True/False, reason) pair.
  2. It then requires the query to start with select (re.match checks the beginning), and separately scans the whole string for dangerous words like insert, update, delete, drop. Either failure blocks the query.
  3. run_query(sql) calls the validator first and raises if it said no — so nothing unsafe ever runs. Only then does it connect.
  4. The connection string ends in ?mode=ro with uri=True — that opens the database read-only. This is a second, independent guard: even if a bad query slipped past the validator, the engine itself refuses to write.

What the output means: For a normal SELECT you get (True, "ok") and the query runs. For a DROP or DELETE you get (False, ...) and it never executes.

Try this: Picture handing this a DROP TABLE customers. The word-scan catches drop and returns False, "write/DDL not allowed" — and even if it didn't, mode=ro would block it at the engine. Two independent locks: that is 'belt and suspenders'.

Belt and suspendersTwo independent controls: (1) the validator rejects anything that isn't a single SELECT; (2) the connection is opened read-only (mode=ro), so even if the validator missed something, a DELETE raises at the SQLite engine level. This is the DevOps capstone's "guardrails in infrastructure, not the prompt" principle, applied to a DB. Verified: python agent/db.py shows DROP blocked.

Step 3 · The self-correcting loop expert

Step 3
Setup to run this snippet
MAX_TRIES = 5           # demo constant (a hard cap)
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
def _explain(*a, **k):  # demo stub
    return _Any()
def _write_sql(*a, **k):  # demo stub
    return _Any()
class _db_t:
    get_schema = 'demo'
    run_query = 'demo'
    validate_sql = 'demo'
    def get_schema(self, *a, **k): return 'demo'
    def run_query(self, *a, **k): return 'demo'
    def validate_sql(self, *a, **k): return 'demo'
    def __getattr__(self, k): return 'demo'
db = _db_t()
agent/engine.pydef ask(question):
    schema = db.get_schema()
    error = None
    for attempt in range(MAX_TRIES):
        q = _write_sql(question, schema, error)      # model writes SQL
        ok, reason = db.validate_sql(q.sql)
        if not ok: error = reason; continue       # feed back: wrong kind of SQL
        try:
            cols, rows = db.run_query(q.sql)
        except Exception as e:
            error = str(e); continue              # feed back: SQL error -> self-correct
        return {"sql": q.sql, "cols": cols, "rows": rows,
                "explanation": _explain(question, q.sql, cols, rows)}
    return {"error": f"no valid query after {MAX_TRIES} tries"}
▶ How this works

This is the heart of the agent — the loop that lets it fix its own mistakes. Instead of writing SQL once and giving up if it's wrong, the agent tries up to MAX_TRIES times, feeding each error back so the next attempt can correct it.

  1. It grabs the schema once (db.get_schema()) and starts with error = None. The for attempt in range(MAX_TRIES) loop gives it a limited number of tries so it can't loop forever.
  2. Each pass, _write_sql(question, schema, error) asks the model for SQL. Crucially it also passes the previous error — so the model knows what went wrong last time and can fix it.
  3. db.validate_sql(q.sql) runs the Step 2 safety check. If it fails, we save the reason into error and continue — jump straight to the next attempt with that feedback.
  4. If it passes, db.run_query(q.sql) executes it. A database error (like a misspelled column) is caught by except, stored in error, and fed back on the next loop. On success it returns the SQL, the rows, and a plain-English _explain(...). If all tries fail, it returns an error dict.

What the output means: Normally you get a dictionary with sql, cols, rows and explanation. The self-correcting part means a first-try mistake often becomes a correct answer by the second try.

Try this: Imagine attempt 1 produces SELECT revenue FROM orders but there's no revenue column. run_query raises, the error text "no such column: revenue" is fed back, and attempt 2 writes SELECT amount ... instead. That feedback loop is the whole trick.

Self-correction is the magicWhen a query errors ("no such column: revenue"), that error is fed back to the model, which fixes the query and retries — often right on the second attempt. This is the Ch 4 agentic loop doing real work, and it dramatically lifts accuracy over one-shot SQL.

Step 4 · Explain the result expert

Step 4
terminalpython agent/engine.py       # needs key
Q: How many customers are in the US?
  SQL: SELECT count(*) FROM customers WHERE country='US'
  -> There are 2 customers in the US.

Q: What's total paid revenue by country?
  SQL: SELECT c.country, sum(o.amount) FROM orders o JOIN customers c
       ON o.customer_id=c.id WHERE o.status='paid' GROUP BY c.country
  -> US leads with $700 in paid revenue, followed by DE ($60)...
▶ How this works

This runs the finished agent end-to-end (this one needs an API key, because a real model writes the SQL). It shows what a user actually sees: their English question, the SQL the agent generated, and a plain-English answer.

  1. Each block starts with Q: — the question typed in plain English, e.g. "How many customers are in the US?".
  2. The SQL: line is the query the agent wrote itself from the schema. Showing it is deliberate — the user (or an analyst) can verify the logic.
  3. The -> line is the explanation: the model turns the raw result rows back into a sentence a non-technical person can read.

What the output means: Two answered questions. The second is more advanced — the agent wrote a JOIN across orders and customers, filtered to paid orders, grouped by country, and then summarised the numbers in words.

Try this: Notice the SQL is always shown, never hidden. That transparency is a trust feature: if the answer looks off, you can read the query and see exactly how it was computed.

Always show the SQLSurfacing the generated query is a trust feature — the user (or an analyst) can verify the logic. Never hide it.

5 · Tests (no key — the safety boundary) expert

Step 5
terminalpython -m pytest tests/ -v
test_select_is_allowed PASSED
test_writes_are_blocked PASSED
test_multiple_statements_blocked PASSED
test_readonly_connection_rejects_writes PASSED
test_a_real_query_returns_rows PASSED
5 passed
▶ How this works

These are the automated tests that prove the safety boundary actually works — and they run with no API key, because the safety logic is plain Python, not the model. Green PASSED lines mean each guarantee held.

  1. test_select_is_allowed — a legitimate SELECT passes the validator, so we haven't blocked real work.
  2. test_writes_are_blocked and test_multiple_statements_blocked — the validator refuses every write (DROP/DELETE/UPDATE/INSERT) and refuses two-statements-in-one injection.
  3. test_readonly_connection_rejects_writes — the second lock: even if a write somehow reached the database, the read-only connection rejects it at the engine.
  4. test_a_real_query_returns_rows — confirms the database and query runner actually work end-to-end.

What the output means: 5 passed means all five safety promises are verified. This is the cheap, fast, key-free proof that running model-generated SQL here is safe.

Try this: These tests are your regression net: if someone later loosens the validator regex, one of these turns red immediately. Run them before trusting any change to the safety code.

✅ Test cases
TestProves
SELECT allowedlegit queries pass the validator
writes blocked (DROP/DELETE/UPDATE/INSERT)the validator refuses every write
multiple statements blockedno SELECT 1; DROP ... injection
read-only connection rejects writeseven a bypassed validator can't write — engine blocks it
real query returns rowsthe DB + runner work end-to-end
All pass with no API key — the entire safety boundary is verified for free.

6 · Evals (needs key) expert

Step 6
terminalpython evals.py
[PASS] How many customers are in the US?
[PASS] What is total paid revenue?
execution accuracy: 2/2
✅ evals passed

Execution accuracy compares the agent's result set to a hand-written gold query's — not the SQL text, since many queries yield the same answer. That's the right metric for text-to-SQL.

▶ How this works

Safety tests prove the agent can't do harm; evals prove it gives the right answer. This measures execution accuracy — the correct metric for text-to-SQL.

  1. Each [PASS] line is one question where the agent's answer matched a hand-written 'gold' query's answer.
  2. It compares the result set (the rows returned), not the SQL text — because many different queries can produce the same correct answer. What matters is whether the numbers are right.
  3. execution accuracy: 2/2 is the score: 2 of 2 questions returned the right result.

What the output means: A per-question pass/fail list plus an overall accuracy fraction, then a final ✅ evals passed. This is how you'd track quality as you add more question types.

Try this: Add a new (question, gold-SQL) pair to the eval set and re-run. If accuracy drops, you've found a question type the agent isn't reliable on yet — exactly the signal used to decide which questions are safe to self-serve.

Troubleshooting expert

⚠️ Common issues
SymptomFix
Agent invents column/table namesEnsure get_schema() is in the prompt; add sample_rows so it sees real values
Query errors it can't fixRaise MAX_TRIES; make sure the error string is fed back in _write_sql
Wrong answer, valid SQLAmbiguous question or wrong join; show the SQL, add example question→SQL pairs (Ch 3)
Validator blocks a legit queryA CTE/subquery with a keyword substring? Refine the regex, but keep it strict — false-block beats false-allow
OperationalError: readonly databaseThat's correct behavior on a write — the read-only guard working

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Milestone 1 — ground the model in the schemaBeginner

Context: Text-to-SQL accuracy starts with grounding. Give the model the exact CREATE TABLE text and it stops inventing columns like revenue that don't exist.

Your task: Build a get_schema() that returns the real CREATE TABLE text the prompt will carry, plus a helper that extracts a table's column names from it.

Requirements:

  • A schema string holding the actual CREATE TABLE statements for the tables
  • get_schema() returns that text verbatim for the prompt
  • A helper parses a CREATE TABLE statement into its list of column names
  • The columns returned match the real schema exactly
  • Explain that grounding on the real schema is what stops invented columns

💡 Hint: Parse column names by slicing between the parentheses of the CREATE TABLE line and taking the first token of each comma-separated field.

Show solution

Schema grounding — the accuracy lever (pure stdlib, runnable):

SCHEMA = """
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, country TEXT, plan TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL,
                     status TEXT, created TEXT);
"""

def get_schema():
    return SCHEMA.strip()

def columns_of(table):
    for line in get_schema().splitlines():
        if line.strip().lower().startswith(f"create table {table}"):
            inside = line[line.index("(")+1 : line.rindex(")")]
            return [c.strip().split()[0] for c in inside.split(",")]
    return []

print(columns_of("customers"))    # ['id', 'name', 'country', 'plan']

Handing the model the real schema is what stops it inventing a revenue column that doesn't exist. Grounding in exact table and column names is the single biggest accuracy lever for text-to-SQL — the model writes against reality, not a guess.

Exercise 2 · Milestone 2 — the read-only validator (belt)Intermediate

Context: The first half of the safety boundary is a validator that runs before any SQL touches the database. It accepts a single SELECT and rejects everything else — writes, DDL, and stacked statements.

Your task: Implement validate_sql() that accepts exactly one SELECT and rejects multiple statements, writes, and DDL, then prove it blocks the dangerous cases.

Requirements:

  • Reject input containing more than one statement (a semicolon separating statements)
  • Require the query to start with SELECT, case-insensitively
  • Reject any write/DDL keyword (insert, update, delete, drop, alter, create, pragma)
  • Return a pass/fail plus a human-readable reason
  • Demonstrate a valid SELECT passing and each dangerous case being blocked

💡 Hint: Regex with word boundaries and case-insensitive flags handles both the SELECT-prefix check and the forbidden-keyword scan; this is the "belt" of belt-and-suspenders.

Show solution

The SQL validator — deterministic, not the model's judgment (pure stdlib, runnable):

import re
def validate_sql(sql):
    s = sql.strip().rstrip(";").strip()
    if ";" in s:
        return False, "multiple statements not allowed"     # block stacked injection
    if not re.match(r"(?is)^\s*select\b", s):
        return False, "SELECT only"
    if re.search(r"(?i)\b(insert|update|delete|drop|alter|create|pragma)\b", s):
        return False, "write/DDL not allowed"
    return True, "ok"

print(validate_sql("SELECT * FROM customers"))              # (True, 'ok')
print(validate_sql("DROP TABLE customers"))                 # (False, ...)
print(validate_sql("SELECT 1; DROP TABLE customers"))       # (False, multiple)

The validator is a deterministic parser check — it accepts exactly one SELECT and rejects writes, DDL, and stacked statements. This is the "belt": the safety boundary lives in plain Python, never in a prompt the model could be tricked into ignoring.

Exercise 3 · Milestone 3 — the read-only connection (suspenders)Advanced

Context: Defense-in-depth means one layer failing is not a breach. Even if the validator misses something, opening the database read-only makes writes impossible at the engine level.

Your task: Open a read-only SQLite connection and show it serving a SELECT while rejecting a write, demonstrating the second redundant guard beneath the validator.

Requirements:

  • Create a throwaway database and seed it with a normal connection
  • Reopen it read-only using the SQLite URI mode
  • A SELECT succeeds on the read-only connection
  • A write (e.g. DELETE) raises an operational error
  • Explain why the connection is the "suspenders" that back up the validator "belt"

💡 Hint: SQLite's file:...?mode=ro URI (with URI parsing enabled) is the whole mechanism — the engine itself refuses the write.

Show solution

The read-only connection — the second, independent guard (pure stdlib, runnable):

import sqlite3, tempfile, os

# build a tiny throwaway DB
path = os.path.join(tempfile.gettempdir(), "analyst_demo.db")
w = sqlite3.connect(path); w.execute("CREATE TABLE IF NOT EXISTS t(x)")
w.execute("INSERT INTO t VALUES (1)"); w.commit(); w.close()

# open READ-ONLY: writes are forbidden by the engine, not by our code
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
print("read works:", con.execute("SELECT count(*) FROM t").fetchone())
try:
    con.execute("DELETE FROM t")
    print("write got through -- BAD")
except sqlite3.OperationalError as e:
    print("write blocked by mode=ro:", str(e)[:40])

Opening the connection as mode=ro makes writes impossible at the database engine, so even a validator miss cannot mutate data. Two independent guards — validator plus read-only connection — is defense in depth; in production the read-only credential also points at a replica, never the primary.

Exercise 4 · Milestone 4 — the self-correcting loopExpert

Context: This is where the analyst becomes an agent. When a query fails validation or execution, feed the error back to the model and retry — bounded — so a wrong-column first attempt gets fixed on the next try.

Your task: Model the agentic loop: on a failed validation or execution, pass the error back to the SQL-writer and retry up to MAX_TRIES, returning the result and the number of tries once it succeeds.

Requirements:

  • A retry loop bounded by MAX_TRIES
  • The SQL writer receives the previous error (None on the first attempt)
  • A validation failure feeds its reason back and retries
  • An execution exception feeds its message back and retries
  • On success, return the SQL, the rows, and the number of tries
  • Show a wrong-column first attempt corrected on the retry

💡 Hint: Carry an error string across iterations and pass it into the writer each time; the loop exits on the first successful run_query.

Show solution

The self-correcting loop — errors become feedback (pure stdlib, runnable):

import re
MAX_TRIES = 5
def validate_sql(sql):
    return (bool(re.match(r"(?is)^\s*select\b", sql.strip())), "SELECT only")

def ask(question, write_sql, run_query):
    error = None
    for attempt in range(MAX_TRIES):
        sql = write_sql(question, error)          # model sees the last error
        ok, reason = validate_sql(sql)
        if not ok:
            error = reason; continue
        try:
            rows = run_query(sql)
        except Exception as e:
            error = str(e); continue              # feed SQL error back and retry
        return {"sql": sql, "rows": rows, "tries": attempt + 1}
    return {"error": f"no valid query after {MAX_TRIES} tries"}

# stub model: first tries a bad column, then fixes it once it sees the error
def write_sql(q, error):
    return "SELECT revenue FROM orders" if not error else "SELECT amount FROM orders"
def run_query(sql):
    if "revenue" in sql: raise Exception("no such column: revenue")
    return [(30,), (60,)]
print(ask("total amount", write_sql, run_query))   # corrected on 2nd try

When run_query raises "no such column", that error string is fed back so the model fixes its own query on the next iteration — the agentic self-correction loop from Chapter 4. The MAX_TRIES cap keeps it from looping forever on a genuinely impossible request.

Exercise 5 · Milestone 5 — evaluate with execution accuracyProfessional

Context: Scoring text-to-SQL on the SQL string is wrong — many different queries return the same answer. The right metric compares the agent's result set to a gold query's result set.

Your task: Implement execution-accuracy scoring over a golden set: run both the agent's SQL and the gold SQL and count a pass when their result sets match, not when their SQL text matches.

Requirements:

  • A golden set of (question, gold_sql) pairs against a seeded database
  • Execute both the agent's query and the gold query and fetch all rows
  • Count a pass when the two result sets are equal
  • Report accuracy as passed-over-total
  • Include a case where different SQL (e.g. count(*) vs count(id)) yields the same rows and still passes

💡 Hint: Compare fetchall() lists, not query strings — equivalent queries with different text must both count as correct.

Show solution

Execution accuracy — compare results, not SQL strings (pure stdlib, runnable):

import sqlite3
con = sqlite3.connect(":memory:")
con.executescript("""
  CREATE TABLE customers(id, country);
  INSERT INTO customers VALUES (1,'US'),(2,'US'),(3,'DE');
""")
def run(sql):
    return con.execute(sql).fetchall()

GOLD = [
    {"q": "how many US customers",
     "gold_sql": "SELECT count(*) FROM customers WHERE country='US'"},
]
# stub agent produces a different-but-equivalent query
def agent_sql(q):
    return "SELECT count(id) FROM customers WHERE country = 'US'"

passed = 0
for case in GOLD:
    if run(agent_sql(case["q"])) == run(case["gold_sql"]):
        passed += 1
print(f"execution accuracy: {passed}/{len(GOLD)}")   # 1/1 -- same result set

Two different queries (count(*) vs count(id)) can be equally correct, so scoring on SQL text would wrongly fail the agent. Execution accuracy — do the result sets match? — is the honest text-to-SQL metric, alongside a hard-fail check that no non-SELECT ever reaches the database.

Exercise 6 · Milestone 6 — safely run generated SQL on real data as ownerIndustry scenario

Context: As owner, the production risk is running model-generated SQL against real data. The milestone composes every guardrail into one gate and enumerates a phased rollout that keeps humans in front of it.

Your task: Compose the full safety boundary — read-only replica credential, validator, read-only connection, timeout, and an auto-applied LIMIT — into a single safe_execute gate, and enumerate the phased rollout.

Requirements:

  • Validate the SQL is a single SELECT and reject write/DDL keywords
  • Auto-append a LIMIT when the query lacks one
  • Target a read-only replica with a read-only connection mode
  • Apply a query timeout as a runaway guard
  • Return the config that was enforced (mode, timeout, limit)
  • Enumerate a phased rollout: analysts verify first, then vetted question types self-serve, always showing the SQL, never touching primary

💡 Hint: Layer the controls so each is independent insurance — credential, validator, connection mode, timeout, LIMIT — and the rollout earns autonomy one vetted question-type at a time.

Show solution

The composed safety gate for production (pure stdlib, runnable):

import re
def safe_execute(sql, run_ro, limit=100, timeout_s=5):
    # 1. validator (belt)
    s = sql.strip().rstrip(";")
    if ";" in s or not re.match(r"(?is)^\s*select\b", s):
        return {"blocked": "not a single SELECT"}
    if re.search(r"(?i)\b(insert|update|delete|drop|alter|create)\b", s):
        return {"blocked": "write/DDL"}
    # 2. always bound the query
    if "limit" not in s.lower():
        s += f" LIMIT {limit}"
    # 3. run on a read-only replica connection (suspenders), with a timeout
    return {"ran": s, "timeout_s": timeout_s, "connection": "replica?mode=ro"}

print(safe_execute("SELECT country, sum(amount) FROM orders GROUP BY country",
                   run_ro=lambda s: None))
print(safe_execute("DELETE FROM orders", run_ro=lambda s: None))   # blocked

Industry scenario: a "talk to your database" tool for non-technical staff. Safety is layered infrastructure, not a prompt: a read-only credential on a replica, a validator, a read-only connection, and a timeout+LIMIT. The rollout is phased — analysts verify SQL first, then vetted question-types go self-serve (always showing the SQL) — and the tool never writes, never touches the primary, and never hides the query.

✓ Checkpoint — done when…

  • The validator blocks every write and multi-statement; the read-only connection blocks writes at the engine.
  • The agent answers English questions with correct SQL, self-correcting on errors.
  • All safety tests pass with no API key.
  • Execution accuracy matches the gold queries.
📋 Master rubric — grade your text-to-SQL agent
DimensionMeets the barAbove the bar (staff-level)
Read-only safetyWrites are impossible two ways: a SELECT-only validator rejects mutating statements and the connection itself is read-only.The validator resists obfuscation (CTEs, stacked statements, comments hiding a write); a bypass attempt fails a dedicated adversarial test.
Schema groundingThe model is grounded in the real schema, so it uses actual table/column names and correct joins rather than inventing them.Grounding scales — only relevant tables are fed for large schemas — and the agent asks/abstains when a question can't be answered from the schema.
Execution accuracyGenerated queries are checked against gold queries for the right answer, not just for running without error.Accuracy is measured on result equivalence (row sets match), catching queries that run cleanly but compute the wrong number.
Self-correction loopSQL errors are fed back so the model repairs the query; the loop is bounded so it can't retry forever.The loop distinguishes recoverable errors (typo, wrong column) from unanswerable questions and stops with an honest can't-answer rather than looping.
Answer explanationThe agent explains the answer in plain language tied to the query it ran, so a non-analyst can trust it.The explanation surfaces assumptions (date range, filters, what was excluded) so a subtly-misread question is caught by the reader.
Cost & blast radiusQuery cost/latency is bounded (row limits, timeouts) so a broad question can't run a table-scan that hammers the warehouse.Expensive queries are estimated and capped before running; the agent runs against a replica/limited role, not the primary.

Score each row 0 (missing) / 1 (meets) / 2 (above). A passing analyst build is 9+/12 with read-only safety at 2 — any path that can mutate data, or that runs an unbounded query against the primary, is an automatic fail regardless of how accurate the SQL is.

Knowledge check check yourself

✓ Knowledge check

Why is the read-only boundary enforced with a read-only database credential on a replica rather than by instructing the model not to write?

Show answer
Even a perfect prompt can be tricked (prompt injection), but a read-only DB user physically cannot execute DROP/DELETE/UPDATE — the guardrail lives in infrastructure/IAM, not the prompt. Validating the query is a single SELECT is belt-and-suspenders on top.
✓ Knowledge check

What role does feeding SQL errors back to the agent play, and why does it improve accuracy?

Show answer
When run_query returns an error like "column x does not exist", that error is returned as a tool result so the agent can read it, fix the query, and retry. This self-correction loop (Ch 4 agentic loop) often gets it right on the second try, dramatically improving accuracy.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in