0Pricing
AI Engineering Academy · Lesson

Building a Natural Language Database Interface

Create a system where users ask questions in plain English, the model generates SQL via function calling, your app executes the query safely, and the model narrates the results.

Building a Natural Language Database Interface is a free AI Engineering Academy lesson on CoddyKit — lesson 4 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 Engineering Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Natural Language to SQL: The Vision

Imagine asking your database 'Which customers spent more than $1,000 last month?' and getting an answer — without writing a single SQL query. A natural language database interface uses function calling to let the LLM generate SQL, your application executes it safely, and the model narrates the results in plain English. This pattern democratizes data access for non-technical users.

System Architecture Overview

The NL-to-SQL pipeline has four components working together:

  • Schema context: The LLM receives your database schema so it knows what tables and columns exist.
  • SQL generation: The model generates a SQL query as a function call argument.
  • Safe execution: Your app validates and runs the query, then returns results.
  • Result narration: The model receives query results and explains them in natural language.

Defining the Query Database Tool

Define a query_database function that accepts a SQL SELECT statement. The schema description in the function definition teaches the model which tables and columns are available, so it generates accurate queries without guessing.

query_db_tool = {
    'type': 'function',
    'function': {
        'name': 'query_database',
        'description': '''Execute a read-only SQL query on the company database.
Use this to answer questions about customers, orders, and products.
Only SELECT statements are allowed. Never use DROP, DELETE, UPDATE, or INSERT.

Available tables:
- customers (id, name, email, created_at, country)
- orders (id, customer_id, total_amount, status, created_at)
- order_items (id, order_id, product_id, quantity, unit_price)
- products (id, name, category, price, stock_quantity)
''',
        'parameters': {
            'type': 'object',
            'properties': {
                'sql': {
                    'type': 'string',
                    'description': 'A valid PostgreSQL SELECT statement.'
                },
                'explanation': {
                    'type': 'string',
                    'description': 'One-sentence explanation of what this query does.'
                }
            },
            'required': ['sql', 'explanation']
        }
    }
}

Safe SQL Execution

Never execute raw SQL from the model without validation. Implement a safety layer that: only allows SELECT statements, rejects dangerous keywords, limits result rows to prevent memory issues, and runs in a read-only database transaction. Defense in depth is critical when executing LLM-generated code.

import re
import psycopg2

DANGEROUS_KEYWORDS = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'TRUNCATE', 'ALTER', 'CREATE', 'EXEC']

def execute_safe_query(sql: str, max_rows: int = 100) -> list:
    '''Execute a read-only SQL query with safety guards.'''
    sql_upper = sql.upper().strip()

    # Only allow SELECT
    if not sql_upper.startswith('SELECT'):
        raise ValueError('Only SELECT statements are allowed.')

    # Block dangerous keywords
    for keyword in DANGEROUS_KEYWORDS:
        if re.search(r'\b' + keyword + r'\b', sql_upper):
            raise ValueError(f'Forbidden keyword: {keyword}')

    conn = psycopg2.connect('postgresql://readonly_user:pass@localhost/appdb')
    with conn:
        with conn.cursor() as cur:
            # Enforce read-only transaction
            cur.execute('SET TRANSACTION READ ONLY')
            cur.execute(sql)
            columns = [desc[0] for desc in cur.description]
            rows = cur.fetchmany(max_rows)
    return [dict(zip(columns, row)) for row in rows]

Injecting Schema Context Into the System Prompt

The model generates better SQL when it can see the full database schema. Build a system prompt that includes table definitions, column names and types, and example values for categorical columns. This lets the model know whether to use country = 'US' or country_code = 'US' without guessing.

SYSTEM_PROMPT = '''You are a data analyst assistant with access to the company database.
When users ask data questions, use the query_database tool to look up the answer.
Always explain your query in plain English before executing it.

Database schema:

CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name VARCHAR NOT NULL,
    email VARCHAR UNIQUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    country VARCHAR(2)  -- ISO 2-letter code: 'US', 'UK', 'DE', etc.
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    total_amount NUMERIC(10,2),
    status VARCHAR  -- 'pending', 'shipped', 'delivered', 'cancelled'
    created_at TIMESTAMPTZ DEFAULT NOW()
);

Only use columns that exist in the schema above.
'''

Formatting Query Results for the Model

Raw database results (lists of dicts) need to be formatted as readable text before sending them back to the model. Convert the result set to a compact representation — a table or JSON summary — that the model can reference when narrating the answer. Avoid sending thousands of rows; summarize large result sets.

import json

def format_results(rows: list, max_display: int = 20) -> str:
    if not rows:
        return 'The query returned no results.'

    total = len(rows)
    display = rows[:max_display]

    # Format as a simple table
    if display:
        columns = list(display[0].keys())
        lines = [' | '.join(columns)]
        lines.append('-' * len(lines[0]))
        for row in display:
            lines.append(' | '.join(str(row[col]) for col in columns))

    result = '\n'.join(lines)
    if total > max_display:
        result += f'\n... ({total - max_display} more rows not shown)'
    return result

Full Pipeline Implementation

Putting it all together: the function that processes a user question, calls the model to generate SQL, executes the query safely, and feeds results back for narration. The model receives both the original question and the query results, then produces a plain-English answer.

from openai import OpenAI
import json

client = OpenAI()

def answer_data_question(user_question: str) -> str:
    messages = [
        {'role': 'system', 'content': SYSTEM_PROMPT},
        {'role': 'user', 'content': user_question}
    ]

    # First call: get SQL from model
    resp = client.chat.completions.create(
        model='gpt-4o', messages=messages, tools=[query_db_tool]
    )
    assistant_msg = resp.choices[0].message
    messages.append(assistant_msg)

    if resp.choices[0].finish_reason == 'tool_calls':
        tc = assistant_msg.tool_calls[0]
        args = json.loads(tc.function.arguments)
        print(f'Executing: {args["explanation"]}')
        print(f'SQL: {args["sql"]}')

        try:
            rows = execute_safe_query(args['sql'])
            result_text = format_results(rows)
        except ValueError as e:
            result_text = f'Query blocked: {str(e)}'

        messages.append({'role': 'tool', 'tool_call_id': tc.id, 'content': result_text})

        # Second call: narrate results
        final = client.chat.completions.create(model='gpt-4o', messages=messages)
        return final.choices[0].message.content

    return assistant_msg.content

Handling Multi-Step Data Questions

Complex questions may require multiple queries. 'Who are our top 5 customers by revenue, and what are their most recent orders?' needs two queries: one to find top customers, then one to get their orders. Allow the model to issue multiple sequential tool calls by running the dispatch loop multiple times until finish_reason='stop'.

def answer_complex_question(user_question: str) -> str:
    messages = [
        {'role': 'system', 'content': SYSTEM_PROMPT},
        {'role': 'user', 'content': user_question}
    ]

    for _ in range(5):  # Max 5 query rounds
        resp = client.chat.completions.create(
            model='gpt-4o', messages=messages, tools=[query_db_tool]
        )
        msg = resp.choices[0].message
        messages.append(msg)

        if resp.choices[0].finish_reason == 'stop':
            return msg.content  # Model is done

        # Process tool call and loop
        tc = msg.tool_calls[0]
        args = json.loads(tc.function.arguments)
        try:
            rows = execute_safe_query(args['sql'])
            result = format_results(rows)
        except Exception as e:
            result = f'Error: {str(e)}'

        messages.append({'role': 'tool', 'tool_call_id': tc.id, 'content': result})

    return 'Could not complete the analysis within the step limit.'

Preventing SQL Injection Risks

Even with the SELECT-only guard, a crafty model (or adversarial user) could try to exfiltrate data via subqueries or comment-based tricks. Additional protections include: using a read-only database user that has SELECT permission only, running in a separate connection pool, and validating that table names in the query match your schema whitelist.

ALLOWED_TABLES = {'customers', 'orders', 'order_items', 'products'}

def validate_tables_in_sql(sql: str) -> bool:
    '''Check that only whitelisted tables are referenced in the query.'''
    import sqlparse
    parsed = sqlparse.parse(sql)[0]
    table_names = set()
    from_seen = False
    for token in parsed.flatten():
        if token.ttype is sqlparse.tokens.Keyword and token.value.upper() in ('FROM', 'JOIN'):
            from_seen = True
        elif from_seen and token.ttype is sqlparse.tokens.Name:
            table_names.add(token.value.lower())
            from_seen = False
    unknown = table_names - ALLOWED_TABLES
    if unknown:
        raise ValueError(f'References unknown tables: {unknown}')
    return True

Caching Common Queries

Many business questions are asked repeatedly with the same answer: 'How many customers do we have?' 'What was last month's revenue?' Cache these results in Redis with a short TTL. Check the cache before executing the query — this reduces database load and speeds up responses for common analytical questions.

import redis
import hashlib
import json

r = redis.Redis.from_url('redis://localhost:6379')

def cached_query(sql: str, ttl_seconds: int = 300) -> list:
    cache_key = 'nl_query:' + hashlib.sha256(sql.encode()).hexdigest()
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    rows = execute_safe_query(sql)
    r.setex(cache_key, ttl_seconds, json.dumps(rows, default=str))
    return rows

Explaining Queries to Users

Build trust by showing users the SQL query that was generated alongside the natural language answer. When users can see 'I ran this query: SELECT COUNT(*) FROM customers WHERE country = ?UK?' they can verify the answer is correct and learn SQL patterns. The explanation field in our tool schema is perfect for this.

Quick Check

Test your understanding of building a natural language database interface.

Lesson Recap

In this lesson you learned: the query_database tool schema injects schema context so the model generates accurate SQL, safety validation must block non-SELECT statements and dangerous keywords before execution, and a loop of model calls enables multi-step data analysis requiring sequential queries. Next up we explore the Model Context Protocol (MCP), the open standard for connecting AI to external tools.

Frequently asked questions

Is the “Building a Natural Language Database Interface” lesson free?

Yes — the full text of “Building a Natural Language Database Interface” is free to read here on the web, and the AI Engineering Academy 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 Engineering Academy course, upgrade to CoddyKit PRO.

What will I learn in “Building a Natural Language Database Interface”?

Create a system where users ask questions in plain English, the model generates SQL via function calling, your app executes the query safely, and the model narrates the results. You practise AI Engineering Academy 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 Engineering Academy?

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

How long does the “Building a Natural Language Database Interface” 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 Engineering Academy lesson?

Yes. Every AI Engineering Academy 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. Defining Function Schemas for the API
  2. Processing Tool Calls in Your Application
  3. Parallel Function Calling
  4. Building a Natural Language Database Interface
← Back to AI Engineering Academy