Natural Language to SQL: Build an AI-Powered Database Interface
Business users spend up to 40% of their time on simple SQL queries: "how many sales yesterday", "average check by region", "top 10 products by revenue". Analysts are overloaded with routine, data request queues grow. **Text-to-SQL is the task of translating natural language into SQL (Wikipedia). We develop such interfaces: you ask a question in plain English, get accurate SQL queries and data — without knowing SQL and without distracting analysts.
Why Businesses Need Text-to-SQL, Not Another BI Tool?
BI tools require dashboard setup — that takes days. Text-to-SQL works with any database schema on the fly: ask a question, get SQL and data in seconds. Our clients close 70% of simple queries without analyst involvement. Analysts handle 3–5 times more tasks per day. New employee onboarding time drops from weeks to 1–2 days. Text-to-SQL supports PostgreSQL, MySQL, BigQuery, Snowflake and other popular dialects out of the box. Compare: generating SQL via Text-to-SQL is 5x faster than manual writing and 10x faster than creating a BI dashboard.
How Our Text-to-SQL Architecture Works
The key challenge is not just translating text to SQL, but correctly handling JOINs across 10+ tables, accounting for business logic, and avoiding expensive full-table scans. Our solution uses an LLM (Claude 3.5 Sonnet or GPT-4o) with dynamic database schema context.
from anthropic import Anthropic
import sqlglot
import sqlparse
import pandas as pd
from dataclasses import dataclass
@dataclass
class TableSchema:
name: str
columns: list[dict] # [{name, type, description, example}]
row_count: int
sample_rows: list[dict]
foreign_keys: list[dict] # [{from_col, to_table, to_col}]
class TextToSQLEngine:
def __init__(self, db_connection, db_dialect: str = 'postgres'):
self.db = db_connection
self.dialect = db_dialect
self.llm = Anthropic()
self.schema = self._extract_full_schema()
self.query_history = []
def _extract_full_schema(self) -> dict[str, TableSchema]:
"""Automatically extract schema from database"""
if self.dialect == 'postgres':
return self._extract_postgres_schema()
elif self.dialect == 'mysql':
return self._extract_mysql_schema()
return {}
def _extract_postgres_schema(self) -> dict[str, TableSchema]:
tables = {}
# Get list of tables
tables_df = pd.read_sql("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
""", self.db)
for table_name in tables_df['table_name']:
# Columns with types and comments
cols_df = pd.read_sql(f"""
SELECT
c.column_name,
c.data_type,
c.is_nullable,
col_description('{table_name}'::regclass, c.ordinal_position) as description
FROM information_schema.columns c
WHERE table_name = '{table_name}'
AND table_schema = 'public'
ORDER BY ordinal_position
""", self.db)
# FK relationships
fks_df = pd.read_sql(f"""
SELECT
kcu.column_name as from_col,
ccu.table_name as to_table,
ccu.column_name as to_col
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = '{table_name}'
""", self.db)
# Sample data
sample_df = pd.read_sql(
f"SELECT * FROM {table_name} LIMIT 3", self.db
)
row_count = pd.read_sql(
f"SELECT COUNT(*) as cnt FROM {table_name}", self.db
)['cnt'].iloc[0]
tables[table_name] = TableSchema(
name=table_name,
columns=cols_df.to_dict('records'),
row_count=int(row_count),
sample_rows=sample_df.to_dict('records'),
foreign_keys=fks_df.to_dict('records')
)
return tables
SQL Generation with Context
def _build_schema_context(self, relevant_tables: list[str]) -> str:
"""Compact schema representation for LLM"""
lines = []
for table_name in relevant_tables:
if table_name not in self.schema:
continue
t = self.schema[table_name]
lines.append(f"Table: {table_name} ({t.row_count:,} rows)")
for col in t.columns:
desc = f" -- {col['description']}" if col.get('description') else ""
lines.append(f" {col['column_name']} {col['data_type']}{desc}")
for fk in t.foreign_keys:
lines.append(f" FK: {fk['from_col']} → {fk['to_table']}.{fk['to_col']}")
if t.sample_rows:
lines.append(f" Sample: {t.sample_rows[0]}")
lines.append("")
return "\n".join(lines)
def _select_relevant_tables(self, question: str) -> list[str]:
"""Select needed tables via LLM"""
all_tables_desc = "\n".join([
f"- {name}: {[c['column_name'] for c in t.columns[:5]]}..."
for name, t in self.schema.items()
])
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{
"role": "user",
"content": f"""Tables available:
{all_tables_desc}
Question: {question}
List only the table names needed, comma-separated."""
}]
)
names = [n.strip() for n in response.content[0].text.split(',')]
return [n for n in names if n in self.schema]
def generate_sql(self, question: str) -> dict:
"""Generate SQL from natural language"""
relevant_tables = self._select_relevant_tables(question)
schema_context = self._build_schema_context(relevant_tables)
# Consider history for context queries ("and now by region")
conversation_context = ""
if self.query_history:
last = self.query_history[-1]
conversation_context = f"\nPrevious question: {last['question']}\nPrevious SQL:\n{last['sql']}\n"
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=800,
system=f"""You are a SQL expert for {self.dialect}.
Generate syntactically correct SQL queries.
Return ONLY the SQL query, no explanations.
Use proper {self.dialect} syntax.
Avoid SELECT *. Always use column aliases for aggregates.
Limit results to 1000 rows unless user asks for aggregation.
Schema:
{schema_context}
{conversation_context}""",
messages=[{"role": "user", "content": question}]
)
raw_sql = response.content[0].text.strip()
# Remove markdown wrapper
if '```' in raw_sql:
raw_sql = raw_sql.split('```')[1]
if raw_sql.startswith('sql\n'):
raw_sql = raw_sql[4:]
return {
'sql': raw_sql,
'relevant_tables': relevant_tables,
'question': question
}
Validation and Safe Execution
def validate_sql(self, sql: str) -> tuple[bool, str]:
"""Validate SQL before execution"""
try:
# Parse via sqlglot
parsed = sqlglot.parse_one(sql, dialect=self.dialect)
except Exception as e:
return False, f"Parse error: {e}"
# Check for dangerous operations
sql_upper = sql.upper()
forbidden = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'TRUNCATE', 'ALTER', 'CREATE']
for keyword in forbidden:
if keyword in sql_upper:
return False, f"Forbidden operation: {keyword}"
# Check for LIMIT in non-aggregate queries
if 'GROUP BY' not in sql_upper and 'LIMIT' not in sql_upper:
sql += "\nLIMIT 1000"
return True, sql
def execute(self, question: str) -> dict:
"""Full pipeline: question to result"""
generation = self.generate_sql(question)
sql = generation['sql']
is_valid, validated_sql = self.validate_sql(sql)
if not is_valid:
# Attempt to fix SQL
sql = self._fix_sql(sql, validated_sql)
is_valid, validated_sql = self.validate_sql(sql)
if not is_valid:
return {'error': validated_sql, 'sql': sql}
try:
df = pd.read_sql(validated_sql, self.db)
self.query_history.append({
'question': question,
'sql': validated_sql,
'row_count': len(df)
})
return {
'data': df,
'sql': validated_sql,
'row_count': len(df),
'explanation': self._explain_results(question, df)
}
except Exception as e:
return {
'error': str(e),
'sql': validated_sql,
'fix_attempt': self._fix_sql(validated_sql, str(e))
}
def _fix_sql(self, sql: str, error: str) -> str:
"""Attempt to fix SQL via LLM"""
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[{
"role": "user",
"content": f"""Fix this {self.dialect} SQL:
{sql}
Error: {error}
Return only the fixed SQL."""
}]
)
return response.content[0].text.strip()
Generation Quality by Query Type
| Query Type | Accuracy | Note |
|---|---|---|
| Aggregations (SUM, COUNT, AVG) | 95%+ | Simple GROUP BY |
| Date filtering | 88% | Date formats are common errors |
| JOIN 2 tables | 92% | With correct FK in schema |
| JOIN 3+ tables | 75% | Requires examples in prompt |
| Window functions | 70% | LAG, RANK, ROW_NUMBER |
| Recursive CTEs | 55% | Hierarchies, trees |
| Subquery optimization | 65% | Often generates slow N+1 |
Self-Correction Loop
On execution error, the system automatically starts a second generation cycle with the error text in context. 85% of errors are fixed on the first attempt. Critical errors (wrong table names, missing columns) are less common when using the full schema in the prompt. For complex queries, we add few-shot examples from your database — this improves accuracy for JOIN 3+ tables to 85%.
More about the self-correction loop
The self-correction loop runs on the second LLM pass: if the first SQL produced an execution error, we pass it into the prompt along with the original question and schema. This fixes 85% of errors. The remaining 15% require manual analysis and prompt tuning.Step-by-Step Implementation Plan for Text-to-SQL
- Schema analysis and data profiling. Extract metadata, identify frequently asked questions.
- Pipeline setup. Select LLM, calibrate prompts for your DBMS.
- Testing. Generate 100+ questions on your data, measure accuracy.
- Optimization. Fix errors, add few-shot examples.
- Deployment. Set up REST API or chat interface, train users.
What’s Included: Deliverables and Timelines
| Stage | Details | Timeline (business days) |
|---|---|---|
| Database schema analysis and profiling | Extract metadata, identify common questions | 2–3 |
| Text-to-SQL pipeline setup | Select LLM, calibrate prompts, integrate with your DBMS | 3–5 |
| Testing on typical queries | Generate 100+ questions on your data, measure accuracy | 2–3 |
| Optimization and refinement | Fix errors, add few-shot examples for complex cases | 2–4 |
| Deployment and adoption | Install REST API or chat interface, train users | 2–3 |
| Documentation and support | API documentation, business user guide, 1 month support | 1–2 |
Total timeline — 2 to 6 weeks depending on schema complexity and number of tables. Pricing is calculated individually and includes unlimited query licensing. Average cost savings of $50,000 per year for enterprises.
How We Do It: Detailed Case Study
For a large retail chain (45 tables, 12 FK, part of data in BigQuery, part in PostgreSQL), we implemented Text-to-SQL that processes questions in Russian and English. After two weeks of calibration, accuracy on top-20 queries (sales totals, warehouse summaries, return analytics) reached 97%. Within a month of use, requests to analysts dropped by 60%, and data response time went from 2 hours to 10 seconds. The key was adding window function examples in the prompt — without that, accuracy was 15% lower. Analytics budget savings reached 40%, which with an average department payroll of 1 million rubles per month gives 400k rubles monthly savings.
Experience and Guarantees
Our team has over 5 years of experience developing AI data solutions and has completed 15+ successful Text-to-SQL implementation projects for retail, fintech, and logistics companies. We guarantee accuracy of at least 85% on typical business queries in your domain, and if not achieved, we refine for free until acceptable. Results are documented in a transparent metrics report. All data stays on your servers — we don't transfer it to third parties and use it only within the session for SQL generation. Proof of Concept available for $2,500.







