0Pricing
AI Agents · Lesson

How NL-to-SQL Agents Work

Schema injection, query generation, execution, and result formatting.

How NL-to-SQL Agents Work is a free AI Agents lesson on CoddyKit — lesson 1 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the AI Agents learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

What Is a NL-to-SQL Agent?

A Natural Language to SQL agent translates plain English questions into SQL queries, executes them against a database, and returns human-readable answers.

Instead of writing SELECT COUNT(*) FROM orders WHERE status='pending', users simply ask: "How many pending orders do we have?"

The Core Architecture

Every NL-to-SQL agent follows the same pipeline:

  1. Schema injection — inject DB structure into the prompt
  2. LLM generates SQL — model produces a query
  3. Execute — run query against the database
  4. Format results — turn rows into readable text
  5. Return answer — respond to the user
# High-level pipeline
def nl_to_sql_agent(user_question, db_connection):
    schema = get_schema(db_connection)
    sql = llm_generate_sql(user_question, schema)
    rows = execute_query(db_connection, sql)
    answer = format_results(rows, user_question)
    return answer

Schema Injection Explained

The LLM has no knowledge of your database structure. You must inject the schema into every prompt so the model knows which tables and columns exist.

A compact schema description tells the model: "Table orders has columns: id, user_id, status, total, created_at."

def build_schema_prompt(schema_info):
    lines = []
    for table in schema_info:
        cols = ', '.join(
            f"{c['name']} ({c['type']})"
            for c in table['columns']
        )
        lines.append(f"Table {table['name']}: {cols}")
    return '\n'.join(lines)

# Output:
# Table users: id (INT), email (VARCHAR), created_at (TIMESTAMP)
# Table orders: id (INT), user_id (INT), status (VARCHAR), total (FLOAT)

if __name__ == '__main__':
    demo_schema = [
        {'name': 'users', 'columns': [{'name': 'id', 'type': 'INT'}, {'name': 'email', 'type': 'VARCHAR'}]},
        {'name': 'orders', 'columns': [{'name': 'id', 'type': 'INT'}, {'name': 'user_id', 'type': 'INT'}]},
    ]
    print(build_schema_prompt(demo_schema))

LLM SQL Generation Prompt

The prompt must give the LLM three things: the schema, the question, and explicit instructions to return only valid SQL.

Being explicit about SELECT-only and the target SQL dialect (PostgreSQL, MySQL, SQLite) is critical for safety and correctness.

SYSTEM_PROMPT = '''You are a SQL expert. Given a database schema and a question,
generate a valid {dialect} SELECT query. Return ONLY the SQL query, no explanation.
Do not use INSERT, UPDATE, DELETE, or DROP.

Schema:
{schema}
'''

def llm_generate_sql(question, schema, dialect='PostgreSQL'):
    prompt = SYSTEM_PROMPT.format(schema=schema, dialect=dialect)
    response = client.chat.completions.create(
        model='gpt-4o',
        messages=[
            {'role': 'system', 'content': prompt},
            {'role': 'user', 'content': question}
        ]
    )
    return response.choices[0].message.content.strip()

Executing the Generated SQL

After the LLM returns SQL, you execute it against the real database. Use parameterized queries where possible and always catch exceptions — the LLM can produce invalid SQL.

Wrapping execution in a try/except lets you retry with an error hint sent back to the LLM.

import psycopg2

def execute_query(conn, sql):
    try:
        with conn.cursor() as cur:
            cur.execute(sql)
            columns = [desc[0] for desc in cur.description]
            rows = cur.fetchmany(100)  # limit rows
            return {'columns': columns, 'rows': rows}
    except psycopg2.Error as e:
        return {'error': str(e), 'sql': sql}

Formatting Results for the User

Raw database rows are not user-friendly. The agent must convert them into a natural language answer.

For small result sets, pass the rows back to the LLM for interpretation. For large sets, compute summary statistics first.

def format_results(result, original_question):
    if 'error' in result:
        return f'Query failed: {result["error"]}'

    rows = result['rows']
    columns = result['columns']

    if not rows:
        return 'No results found.'

    # For simple counts/aggregates — just return the value
    if len(columns) == 1 and len(rows) == 1:
        return f'Result: {rows[0][0]}'

    # For multi-row results — summarize
    summary = f'Found {len(rows)} rows.\n'
    for row in rows[:5]:  # show first 5
        summary += ', '.join(f'{columns[i]}: {row[i]}' for i in range(len(columns))) + '\n'
    return summary

if __name__ == '__main__':
    demo_result = {'rows': [[42]], 'columns': ['count']}
    print(format_results(demo_result, 'How many users signed up?'))
    demo_result2 = {'rows': [], 'columns': ['id']}
    print(format_results(demo_result2, 'Any orders today?'))

Why NL-to-SQL Is Hard: Ambiguity

Ambiguity is the biggest challenge. Consider: "Show me top customers."

  • Top by revenue? By order count? By recency?
  • Last month? All time?
  • Top 10? Top 100?

Humans understand context; LLMs make assumptions. Agents need strategies to handle or clarify ambiguous questions.

AMBIGUITY_PROMPT = '''If the question is ambiguous, respond with JSON:
{"needs_clarification": true, "question": "your clarifying question"}

If clear, respond with the SQL query directly.

User question: {question}
'''

def generate_or_clarify(question, schema):
    response = llm_call(AMBIGUITY_PROMPT.format(
        question=question, schema=schema
    ))
    if '"needs_clarification"' in response:
        import json
        return json.loads(response)
    return {'sql': response}

Why NL-to-SQL Is Hard: Schema Size

Enterprise databases can have hundreds of tables and thousands of columns. Injecting the full schema would exceed the LLM's context window.

Solutions include: schema search (embed table descriptions, retrieve relevant ones), table filtering (ask LLM which tables are needed first), and schema compression (omit index and audit columns).

# Two-phase approach for large schemas
def get_relevant_tables(question, all_tables):
    prompt = f'''Given these tables: {all_tables}
Which 3-5 tables are most relevant to answer: "{question}"?
Return a JSON list of table names only.'''
    response = llm_call(prompt)
    import json
    return json.loads(response)

def nl_to_sql_large_db(question, conn):
    all_tables = list_all_tables(conn)  # just names
    relevant = get_relevant_tables(question, all_tables)
    schema = get_schema_for_tables(conn, relevant)
    return llm_generate_sql(question, schema)

Why NL-to-SQL Is Hard: SQL Dialect Differences

SQL is not universal. LIMIT in PostgreSQL/MySQL becomes TOP in SQL Server. Date functions differ across databases. The LLM must know which dialect to use.

Always include the target dialect in your system prompt and consider adding dialect-specific examples in few-shot prompting.

DIALECT_EXAMPLES = {
    'postgresql': 'Use LIMIT for row limits. Use NOW() for current time.',
    'mysql': 'Use LIMIT for row limits. Use NOW() for current time.',
    'sqlite': 'Use LIMIT. Use datetime("now") for current time.',
    'mssql': 'Use TOP N for row limits. Use GETDATE() for current time.',
    'bigquery': 'Use LIMIT. Use CURRENT_TIMESTAMP() for current time. Use backtick for table names.'
}

def get_dialect_hint(dialect):
    return DIALECT_EXAMPLES.get(dialect.lower(), '')

if __name__ == '__main__':
    for dialect in ['postgresql', 'sqlite', 'mssql']:
        print(f'{dialect}: {get_dialect_hint(dialect)}')

Error Recovery Loop

Generated SQL often fails on the first try. A robust agent implements an error recovery loop: send the failed SQL and the error message back to the LLM and ask it to fix the query.

Limit retries to 2-3 to avoid infinite loops on unfixable queries.

def nl_to_sql_with_retry(question, schema, conn, max_retries=3):
    sql = llm_generate_sql(question, schema)
    for attempt in range(max_retries):
        result = execute_query(conn, sql)
        if 'error' not in result:
            return format_results(result, question)
        # Ask LLM to fix the error
        fix_prompt = f'The SQL query failed with error: {result["error"]}\n'\
                     f'Original SQL: {sql}\n'\
                     f'Please fix the SQL query.'
        sql = llm_call(fix_prompt)
        print(f'Retry {attempt + 1} with fixed SQL')
    return 'Could not generate a valid query after retries.'

Putting It All Together

A production NL-to-SQL agent combines all pieces: schema retrieval, prompt construction, SQL generation, validation, execution, error recovery, and result formatting.

Adding query caching (same question → same SQL) dramatically reduces latency and LLM costs for repeated queries.

import hashlib

query_cache = {}

def cached_nl_to_sql(question, schema_hash, conn):
    cache_key = hashlib.md5((question + schema_hash).encode()).hexdigest()
    if cache_key in query_cache:
        print('Cache hit!')
        sql = query_cache[cache_key]
    else:
        schema = get_schema(conn)
        sql = llm_generate_sql(question, schema)
        query_cache[cache_key] = sql

    result = execute_query(conn, sql)
    return format_results(result, question)

Knowledge Check

What is the correct order of steps in a NL-to-SQL agent pipeline?

Recap: NL-to-SQL Architecture

NL-to-SQL agents convert natural language questions into executable SQL queries via a structured pipeline: inject schema → generate SQL → execute → format → return.

Key challenges are ambiguity in user questions, large schema sizes that exceed context windows, and SQL dialect differences across databases. Error recovery loops handle LLM-generated SQL that fails on the first execution.

Frequently asked questions

Is the “How NL-to-SQL Agents Work” lesson free?

Yes — the full text of “How NL-to-SQL Agents Work” is free to read here on the web, and the AI Agents course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the AI Agents course, upgrade to CoddyKit PRO.

What will I learn in “How NL-to-SQL Agents Work”?

Schema injection, query generation, execution, and result formatting. You practise AI Agents with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start AI Agents?

No prior experience is required. AI Agents on CoddyKit is structured for beginners through advanced learners; this is — lesson 1 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “How NL-to-SQL Agents Work” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this AI Agents lesson?

Yes. Every AI Agents lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. How NL-to-SQL Agents Work
  2. Schema Understanding and Injection
  3. Generating and Validating SQL Queries
  4. Handling Ambiguous Database Questions
← Back to AI Agents