BeClaude
GuideBeginnerBest Practices2026-05-22

Building a Robust Text-to-SQL System with Claude: A Practical Guide

Learn how to build a production-ready Text-to-SQL system using Claude. Covers prompting, RAG for complex schemas, chain-of-thought reasoning, and self-improvement loops.

Quick Answer

This guide teaches you to build a Text-to-SQL system using Claude, covering basic prompting, few-shot learning, chain-of-thought reasoning, RAG for large schemas, and self-improvement loops that let Claude fix its own SQL errors.

Text-to-SQLPrompt EngineeringRAGDatabaseClaude API

Building a Robust Text-to-SQL System with Claude: A Practical Guide

Imagine giving your non-technical team members the ability to ask your database complex questions in plain English—and getting accurate SQL queries back instantly. That's the promise of Text-to-SQL, and Claude is exceptionally well-suited for this task. This guide walks you through building a production-ready Text-to-SQL system, from basic prompting to advanced self-improvement loops.

Why Text-to-SQL Matters

Text-to-SQL bridges the gap between natural language and structured data. Here's why it's transformative:

  • Democratizes data access: Non-technical stakeholders can query databases without learning SQL syntax
  • Boosts analyst productivity: Data professionals can prototype complex queries in seconds
  • Enables intuitive interfaces: Power chatbots and applications with natural language database interaction
  • Handles complexity: Claude can generate queries with multiple JOINs, subqueries, and aggregations that would take humans significant time to write

Setting Up Your Environment

First, let's create a test SQLite database with two tables: employees and departments. This will be our sandbox throughout the guide.

import sqlite3

Create an in-memory SQLite database

conn = sqlite3.connect(':memory:') cursor = conn.cursor()

Create departments table

cursor.execute(''' CREATE TABLE departments ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, location TEXT ) ''')

Create employees table

cursor.execute(''' CREATE TABLE employees ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, department_id INTEGER, salary REAL, hire_date DATE, FOREIGN KEY (department_id) REFERENCES departments (id) ) ''')

Insert sample data

cursor.executemany('INSERT INTO departments VALUES (?, ?, ?)', [ (1, 'Engineering', 'New York'), (2, 'Marketing', 'San Francisco'), (3, 'Sales', 'Chicago') ])

cursor.executemany('INSERT INTO employees VALUES (?, ?, ?, ?, ?)', [ (1, 'Alice Johnson', 1, 95000, '2020-03-15'), (2, 'Bob Smith', 2, 75000, '2021-07-01'), (3, 'Charlie Brown', 1, 88000, '2019-11-20'), (4, 'Diana Prince', 3, 82000, '2022-01-10') ])

conn.commit()

Step 1: Building a Basic Text-to-SQL Prompt

The foundation of any Text-to-SQL system is a well-structured prompt. Your prompt must include:

  • Clear instructions for the task
  • The user's natural language query
  • The database schema so Claude understands the available tables and columns
import anthropic

client = anthropic.Anthropic()

def get_schema(conn): cursor = conn.cursor() cursor.execute("SELECT sql FROM sqlite_master WHERE type='table'") schemas = cursor.fetchall() return '\n'.join([s[0] for s in schemas])

def generate_prompt_basic(user_query, schema): return f"""You are a SQL expert. Given the following database schema, convert the user's natural language query into a valid SQL query.

Database Schema: {schema}

User Query: {user_query}

Generate only the SQL query, nothing else."""

Test it

schema = get_schema(conn) prompt = generate_prompt_basic("Show me all employees in Engineering", schema)

response = client.messages.create( model="claude-3-sonnet-20241022", max_tokens=200, messages=[{"role": "user", "content": prompt}] )

print(response.content[0].text)

Output: SELECT * FROM employees WHERE department_id = 1;

Step 2: Improving with Few-Shot Examples

Basic prompts work, but few-shot learning dramatically improves accuracy. By providing examples of input-output pairs, Claude learns the expected format and reasoning pattern.

def generate_prompt_few_shot(user_query, schema):
    examples = """
Example 1:
User: List all departments in San Francisco
SQL: SELECT * FROM departments WHERE location = 'San Francisco';

Example 2: User: What is the average salary by department? SQL: SELECT d.name, AVG(e.salary) as avg_salary FROM employees e JOIN departments d ON e.department_id = d.id GROUP BY d.name;

Example 3: User: Who was hired most recently? SQL: SELECT name, hire_date FROM employees ORDER BY hire_date DESC LIMIT 1; """ return f"""You are a SQL expert. Convert natural language queries to SQL.

Database Schema: {schema}

{examples}

User: {user_query} SQL:"""

Pro tip: Including a few sample rows of real data in your prompt can give Claude valuable context about data types and value formats, leading to more accurate queries.

Step 3: Chain-of-Thought Prompting for Complex Queries

For complex queries involving multiple joins, aggregations, or subqueries, chain-of-thought (CoT) prompting helps Claude reason step-by-step. Using XML tags to structure the reasoning process is particularly effective.

def generate_prompt_cot(user_query, schema):
    return f"""You are a SQL expert. Convert the user's query to SQL by reasoning step-by-step.

Database Schema: {schema}

<reasoning>

  • Identify the tables needed
  • Determine the columns to select
  • Identify any JOIN conditions
  • Apply WHERE filters
  • Add GROUP BY, ORDER BY, or LIMIT if needed
  • Construct the final SQL
</reasoning>

User Query: {user_query}

Provide your reasoning in <reasoning> tags, then output the SQL in <sql> tags."""

response = client.messages.create( model="claude-3-sonnet-20241022", max_tokens=500, messages=[{"role": "user", "content": generate_prompt_cot( "Which department has the highest average salary?", schema )}] )

Claude will output something like:

<reasoning>
  • I need the departments table for department names and employees table for salaries
  • I need to calculate average salary per department
  • JOIN on department_id
  • GROUP BY department name
  • ORDER BY average salary descending
  • LIMIT 1 to get the highest
</reasoning>

<sql> SELECT d.name, AVG(e.salary) as avg_salary FROM departments d JOIN employees e ON d.id = e.department_id GROUP BY d.name ORDER BY avg_salary DESC LIMIT 1; </sql>

Step 4: RAG for Complex Database Schemas

When your database has dozens or hundreds of tables, including the entire schema in every prompt is impractical. Retrieval Augmented Generation (RAG) solves this by dynamically fetching only the relevant schema information.

Here's how to implement RAG using VoyageAI embeddings:

import voyageai

class VectorDB: def __init__(self): self.client = voyageai.Client() self.embeddings = [] self.metadata = [] def add_documents(self, documents, metadata_list): response = self.client.embed(documents, model="voyage-2") self.embeddings.extend(response.embeddings) self.metadata.extend(metadata_list) def search(self, query, k=3): query_embedding = self.client.embed([query], model="voyage-2").embeddings[0] # Calculate cosine similarity similarities = [ cosine_similarity(query_embedding, emb) for emb in self.embeddings ] top_indices = sorted( range(len(similarities)), key=lambda i: similarities[i], reverse=True )[:k] return [self.metadata[i] for i in top_indices]

Index your table schemas

vector_db = VectorDB() table_descriptions = [ "employees: id, name, department_id, salary, hire_date - employee records", "departments: id, name, location - company departments" ] vector_db.add_documents(table_descriptions, [ {"table": "employees", "schema": "CREATE TABLE employees (...)"}, {"table": "departments", "schema": "CREATE TABLE departments (...)"} ])

def generate_prompt_rag(user_query): relevant_schemas = vector_db.search(user_query, k=2) schema_context = "\n".join([s["schema"] for s in relevant_schemas]) return f"""Using the relevant schema context, convert the query to SQL.

Relevant Schema: {schema_context}

User Query: {user_query} SQL:"""

Step 5: Self-Improvement Loop

The most powerful technique is letting Claude execute its own SQL, analyze the results, and fix errors. This creates a self-correcting system.

def text_to_sql_with_self_improvement(user_query, conn, max_iterations=3):
    schema = get_schema(conn)
    
    for i in range(max_iterations):
        # Generate SQL
        prompt = generate_prompt_cot(user_query, schema)
        response = client.messages.create(
            model="claude-3-sonnet-20241022",
            max_tokens=500,
            messages=[{"role": "user", "content": prompt}]
        )
        
        # Extract SQL from response
        sql = extract_sql(response.content[0].text)
        
        try:
            cursor = conn.cursor()
            cursor.execute(sql)
            results = cursor.fetchall()
            
            # If successful, return results
            if i == 0:
                return sql, results
            
            # Check if results make sense
            validation_prompt = f"""The SQL query:
{sql}

Returned these results: {results}

Does this correctly answer the user's question: "{user_query}"? If not, explain what's wrong.""" validation = client.messages.create( model="claude-3-sonnet-20241022", max_tokens=200, messages=[{"role": "user", "content": validation_prompt}] ) if "correct" in validation.content[0].text.lower(): return sql, results except Exception as e: # SQL execution error - ask Claude to fix it error_prompt = f"""The SQL query: {sql}

Produced this error: {str(e)}

Fix the SQL query.""" response = client.messages.create( model="claude-3-sonnet-20241022", max_tokens=500, messages=[{"role": "user", "content": error_prompt}] ) sql = extract_sql(response.content[0].text) return sql, "Max iterations reached"

Key Takeaways

  • Start simple, then layer complexity: Begin with basic prompts, add few-shot examples, then chain-of-thought reasoning, and finally RAG and self-improvement loops
  • Schema context is critical: Always provide Claude with the database schema. For large databases, use RAG to dynamically retrieve relevant schema information
  • Self-improvement loops catch errors: Let Claude execute its SQL, analyze results, and fix mistakes automatically—this dramatically improves reliability
  • Include sample data: Adding a few rows of real data alongside the schema helps Claude understand data types and value formats
  • XML tags improve structured reasoning: Using <reasoning> and <sql> tags helps Claude separate its thought process from the final output, making debugging easier