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 resultFull 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.contentHandling 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 TrueCaching 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 rowsExplaining 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
- Defining Function Schemas for the API
- Processing Tool Calls in Your Application
- Parallel Function Calling
- Building a Natural Language Database Interface