How to Build an AI SQL Agent That Understands Natural Language and Queries Databases
Learn how to build a secure AI SQL agent in Python using SQLGlot AST linting, read-only database sessions, schema pruning, and self-correcting query loops.
Executive summary
How to Build: How to Build an AI SQL Agent That Understands Natural Language and Queries Databases (Summary & Mechanics)
Learn how to build a secure AI SQL agent in Python using SQLGlot AST linting, read-only database sessions, schema pruning, and self-correcting query loops. This system couples deterministic validation loops with modern AI orchestration to eliminate hallucinations, enforce type contracts, and guarantee enterprise production stability.
- Architecture
- Deterministic Agent Graph
- Primary Stack
- Python 3.12 / FastAPI / Pydantic
- Part Series
- Part I: Practical AI Agents
- Verified As Of
- October 2026
Who This Guide Is For
- Production automation requiring verifiable citation & error boundaries
- Self-hosted privacy-first deployments with open-source alternatives
Important Considerations
- Enforce hard recursion limits to prevent cyclic execution drift
- Sanitize untrusted inputs to prevent indirect prompt injection
Introduction
To build an AI SQL agent that translates natural-language questions into accurate PostgreSQL, MySQL, or SQLite queries without exposing your database to destructive mutations or runaway full-table scans, you must implement three mandatory defence-in-depth layers: (1) AST-level SQL parsing and linting using SQLGlot to block any non-SELECT statement or unauthorized table access before execution, (2) database-enforced read-only transactions with statement timeouts, and (3) schema pruning with bounded self-correction loops.
Tip: Architecture Reference: Internal Link Suggestion: On Aniket.app (https://aniket.app/), see the Prompt BI engineering system overview for how AST query linting blocks unsafe SQL mutations in production analytics pipelines, and cross-reference Article 10: How to Build an AI Data Analyst.
Natural-Language-to-SQL Architecture & Security Boundaries
flowchart TD
Question([User Natural Language Question]) --> Ambiguity{Ambiguity & Schema Matcher}
Ambiguity -- "Missing Metric Definition" --> Clarify([Ask User for Clarification])
Ambiguity -- "Clear Intent" --> SchemaPruner[DDL & Few-Shot SQL Retriever]
SchemaPruner --> Generator[LLM SQL Generator]
Generator --> ASTValidator{SQLGlot AST Linter: Single SELECT + Allowed Tables + LIMIT}
ASTValidator -- "Blocked DDL/DML or Syntax Error" --> Generator
ASTValidator -- "Valid Read-Only AST" --> Executor[(Read-Only DB Session + Timeout)]
Executor -- "Database Execution Error & retries < 2" --> Generator
Executor -- "Query Result Rows" --> Explainer[Natural Language Summary & Chart Spec]Why Regex Filtering Fails and SQLGlot AST Validation Is Required
Many naive tutorials check if sql.upper().startswith("SELECT"): before executing LLM-generated SQL. That check is trivially bypassed by stacked queries (SELECT 1; DROP TABLE users;), writable Common Table Expressions (WITH deleted AS (DELETE FROM orders RETURNING *) SELECT * FROM deleted;), or system catalog functions (pg_read_file()). Parsing the query into an Abstract Syntax Tree (AST) with SQLGlot (https://github.com/tobymao/sqlglot) lets you inspect every node in the tree deterministically, verify that the root expression is strictly exp.Select, confirm zero Delete/Update/Drop/Insert/Command nodes exist anywhere in the tree, verify every exp.Table against an allowlist, and automatically inject a LIMIT 100 cap.
Technology Comparison: Paid vs. Open-Source Stack
Repository Verification Note: While vanna-ai/vanna (https://github.com/vanna-ai/vanna) (MIT License, 23.8k stars) is a well-known open-source Text-to-SQL framework, our repository audit on October 30, 2026 confirmed that
vanna-ai/vannawas archived by its owner on March 29, 2026 and is now read-only. For actively maintained production systems in 2026, we recommend building a clean pipeline with SQLGlot (https://github.com/tobymao/sqlglot) + SQLAlchemy (https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy) + LangGraph (https://github.com/langchain-ai/langgraph) / Pydantic.
| Component | Commercial / Managed Service | Open-Source Alternative | Self-Hosting Possible? | Maintenance & Technical Notes |
|---|---|---|---|---|
| SQL AST Parser & Linter | Proprietary BI Gateways | SQLGlot (https://github.com/tobymao/sqlglot) (MIT License, active, 9.7k stars) | Yes | Zero dependencies; parses and transpiles 30+ SQL dialects including Postgres, MySQL, SQLite, DuckDB, and Snowflake. |
| Text-to-SQL Orchestration | Snowflake Cortex Analyst / Databricks Genie | LangGraph (https://github.com/langchain-ai/langgraph) (MIT, active) / Vanna (https://github.com/vanna-ai/vanna) (MIT, archived Mar 29, 2026) | Yes | Custom LangGraph + SQLGlot gives full control over AST security and dialect transpilation. |
| Database Engine | AWS RDS / Supabase / Neon | PostgreSQL (https://www.postgresql.org/docs/) / MySQL / SQLite + SQLAlchemy (https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy) (MIT) | Yes | Always connect using a dedicated readonly_ai_user database role. |
| SQL Generation LLM | OpenAI (gpt-4o-mini / gpt-4.1) / Claude | Ollama (https://github.com/ollama/ollama) (Qwen2.5-Coder-32B / SeekCoder) | Yes | Coding-tuned open-weight models (Qwen2.5-Coder) match commercial models on multi-join SQL generation. |
Recommended Implementation Options
Option A: Best Practical Implementation (Recommended): Python 3.12 + FastAPI + SQLGlot (https://github.com/tobymao/sqlglot) AST validator + SQLAlchemy (https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy) read-only connection pool + OpenAI Structured Outputs (or Qwen2.5-Coder-32B).
Option B: Fully Open-Source Local Implementation: SQLGlot (https://github.com/tobymao/sqlglot) + SQLAlchemy (https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy) + PostgreSQL/SQLite + local Ollama (https://github.com/ollama/ollama) (qwen2.5-coder:14b or 32b).
Option C: Production Enterprise Analytics Stack: FastAPI + PostgreSQL read replica + SET LOCAL statement_timeout = '5000ms' + pgvector few-shot query bank + tenant row-level security (RLS).
Step-by-Step Implementation Guide
Step 1: Project Structure
ai-sql-agent/
├── app/
│ ├── __init__.py
│ ├── ast_guard.py
│ ├── db_executor.py
│ └── sql_agent.py
├── tests/
│ └── test_sql_agent.py
└── requirements.txt
Step 2: Install Dependencies
pip install sqlglot==25.24.0 sqlalchemy==2.0.35 pydantic==2.9.2 openai==1.51.0 pytest==8.3.3
Step 3: Implement the SQLGlot AST Security Linter (app/ast_guard.py)
This module parses raw SQL into an AST, rejects multi-statement injections, bans DDL/DML mutations anywhere in the tree, verifies referenced tables against an allowlist, blocks dangerous system functions, and enforces a row limit:
from typing import Set
import sqlglot
from sqlglot import exp
FORBIDDEN_AST_NODES = (
exp.Insert,
exp.Update,
exp.Delete,
exp.Drop,
exp.Create,
exp.Alter,
exp.Command,
exp.Grant,
exp.Revoke,
exp.Transaction,
exp.Commit,
exp.Rollback,
exp.Copy,
exp.Pragma,
)
FORBIDDEN_FUNCTIONS = {
"pg_read_file",
"pg_ls_dir",
"pg_execute_server_program",
"pg_sleep",
"dblink",
"lo_import",
"lo_export",
"load_extension",
}
def validate_and_rewrite_readonly_sql(
raw_sql: str,
allowed_tables: Set[str],
dialect: str = "postgres",
max_limit: int = 100,
) -> str:
"""Validates SQL via SQLGlot AST inspection and enforces a hard LIMIT cap."""
try:
parsed_statements = sqlglot.parse(raw_sql, read=dialect)
except sqlglot.errors.ParseError as exc:
raise ValueError(f"SQL syntax parse error: {exc}") from exc
# 1. Block stacked multi-statement queries (e.g. SELECT 1; DROP TABLE x;)
non_empty_stmts = [s for s in parsed_statements if s is not None]
if len(non_empty_stmts) != 1:
raise PermissionError("Exactly one SQL statement is permitted per query.")
ast = non_empty_stmts[0]
# 2. Root expression must be a SELECT or UNION of SELECTs
if not isinstance(ast, (exp.Select, exp.Union)):
raise PermissionError(f"Only SELECT queries are permitted. Found root: {type(ast).__name__}")
# 3. Walk the entire AST to block writable CTEs or DDL/DML nodes
for node in ast.walk():
if isinstance(node, FORBIDDEN_AST_NODES):
raise PermissionError(f"Forbidden SQL operation detected in AST: {type(node).__name__}")
if isinstance(node, exp.Anonymous) and node.name.lower() in FORBIDDEN_FUNCTIONS:
raise PermissionError(f"Forbidden database function called: {node.name}")
if isinstance(node, exp.Func) and node.sql_name().lower() in FORBIDDEN_FUNCTIONS:
raise PermissionError(f"Forbidden database function called: {node.sql_name()}")
# 4. Allowlist check on every referenced table (excluding CTE aliases)
cte_aliases = {cte.alias_or_name.lower() for cte in ast.find_all(exp.CTE)}
allowed_lower = {t.lower() for t in allowed_tables}
for table_node in ast.find_all(exp.Table):
tbl_name = table_node.name.lower()
if tbl_name not in cte_aliases and tbl_name not in allowed_lower:
raise PermissionError(f"Query references unauthorized or unknown table: '{table_node.name}'")
# 5. Enforce or cap LIMIT clause on top-level SELECT
if isinstance(ast, exp.Select):
existing_limit = ast.args.get("limit")
if existing_limit is None:
ast = ast.limit(max_limit)
else:
try:
limit_val = int(existing_limit.expression.name)
if limit_val > max_limit:
ast = ast.limit(max_limit)
except (ValueError, AttributeError):
ast = ast.limit(max_limit)
return ast.sql(dialect=dialect)
Step 4: Implement Read-Only Database Execution (app/db_executor.py)
For SQLite, we open the database using mode=ro URI or PRAGMA query_only = ON. For PostgreSQL, we execute SET LOCAL TRANSACTION READ ONLY; and SET LOCAL statement_timeout = '5000ms';:
import sqlite3
from typing import List, Dict, Any, Set
from app.ast_guard import validate_and_rewrite_readonly_sql
def execute_safe_sqlite_query(
db_path: str,
raw_sql: str,
allowed_tables: Set[str],
max_limit: int = 100,
) -> Dict[str, Any]:
"""Validates SQL via SQLGlot AST and executes it against a read-only SQLite connection."""
safe_sql = validate_and_rewrite_readonly_sql(
raw_sql=raw_sql,
allowed_tables=allowed_tables,
dialect="sqlite",
max_limit=max_limit,
)
conn = sqlite3.connect(db_path, timeout=5.0)
conn.row_factory = sqlite3.Row
try:
conn.execute("PRAGMA query_only = ON;")
cursor = conn.execute(safe_sql)
rows = [dict(row) for row in cursor.fetchall()]
columns = [desc[0] for desc in cursor.description] if cursor.description else []
return {
"executed_sql": safe_sql,
"columns": columns,
"row_count": len(rows),
"rows": rows,
}
finally:
conn.close()
Testing the AI SQL Agent (tests/test_sql_agent.py)
import sqlite3
import pytest
from app.ast_guard import validate_and_rewrite_readonly_sql
from app.db_executor import execute_safe_sqlite_query
ALLOWED = {"customers", "orders", "order_items"}
def test_ast_guard_blocks_stacked_queries_and_writable_ctes():
# Block stacked DROP TABLE
with pytest.raises(PermissionError, match="Exactly one SQL statement"):
validate_and_rewrite_readonly_sql("SELECT * FROM orders; DROP TABLE orders;", ALLOWED)
# Block writable CTE
with pytest.raises(PermissionError, match="Forbidden SQL operation"):
validate_and_rewrite_readonly_sql(
"WITH d AS (DELETE FROM orders RETURNING *) SELECT * FROM d", ALLOWED
)
# Block unauthorized table (e.g., payroll_salaries)
with pytest.raises(PermissionError, match="unauthorized or unknown table"):
validate_and_rewrite_readonly_sql("SELECT * FROM payroll_salaries", ALLOWED)
def test_ast_guard_allows_cte_and_injects_limit():
sql = """
WITH regional_totals AS (
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT c.name, r.total_spent
FROM customers c
JOIN regional_totals r ON c.id = r.customer_id
ORDER BY r.total_spent DESC
"""
safe_sql = validate_and_rewrite_readonly_sql(sql, ALLOWED, dialect="sqlite", max_limit=50)
assert "LIMIT 50" in safe_sql
def test_end_to_end_readonly_sqlite_execution(tmp_path):
db_file = str(tmp_path / "analytics.db")
conn = sqlite3.connect(db_file)
conn.executescript(
"""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, region TEXT);
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL);
INSERT INTO customers VALUES (1, 'Acme Corp', 'APAC'), (2, 'Globex', 'NA');
INSERT INTO orders VALUES (101, 1, 450.0), (102, 1, 550.0), (103, 2, 300.0);
"""
)
conn.close()
res = execute_safe_sqlite_query(
db_file,
"SELECT c.name, SUM(o.amount) AS revenue FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.name",
allowed_tables={"customers", "orders"},
)
assert res["row_count"] == 2
assert res["rows"][0]["revenue"] == 1000.0
Handling Ambiguous Questions, Query Optimisation & Cost
- Handling Ambiguous Questions: When a user asks "Who are our top customers?", the term "top" could mean highest gross revenue, highest net margin, or highest order count. Include an ambiguity classification field (is_ambiguous: bool, clarification_question: Optional[str]) in the agent's structured output schema so it asks "Would you like top customers ranked by gross order revenue or total order count?" when metric definitions are unspecified.
- Schema Pruning for Large Databases: If an enterprise database has 400 tables, passing all 400 CREATE TABLE DDLs overflows the prompt and degrades join accuracy. Embed table descriptions into pgvector (https://github.com/pgvector/pgvector) and retrieve only the top 8–10 relevant table schemas plus foreign-key neighbours per query.
Useful Tools, Documentation and GitHub Repositories
| Resource | Purpose | Type | Link |
|---|---|---|---|
| SQLGlot | Python SQL AST parser, linter, optimizer, and transpiler | Open-Source (MIT) | https://github.com/tobymao/sqlglot |
| SQLAlchemy | Python SQL toolkit and database connection pooling | Open-Source (MIT) | https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy |
| PostgreSQL Docs | Read-only roles, row-level security, and statement timeouts | Documentation | https://www.postgresql.org/docs/ |
| Vanna (Archived) | Historical Text-to-SQL reference (archived Mar 29, 2026) | Open-Source (MIT) | https://github.com/vanna-ai/vanna |
Frequently Asked Questions
Why is regex checking (sql.startswith("SELECT")) insufficient for SQL agent security?
Attackers or hallucinating LLMs can execute data-modifying statements inside a SELECT using PostgreSQL writable CTEs (WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM del) or stacked queries (SELECT 1; DROP TABLE users). Only AST traversal via SQLGlot (https://github.com/tobymao/sqlglot) combined with a read-only database user blocks these vectors reliably.
How do I prevent an AI-generated SQL query from locking up my production database with a Cartesian join?
Always run the AI SQL agent against a read replica, enforce SET LOCAL statement_timeout = '5000ms' inside the transaction, and run EXPLAIN (FORMAT JSON) prior to execution if the database contains multi-million-row tables.
What happens if the LLM writes a column name that doesn't exist?
Wrap execute_safe_sqlite_query in a 2-attempt self-correction loop: catch OperationalError / ProgrammingError, pass the exact database error message (no such column: o.total_price) and the table DDL back to the LLM, and re-validate the repaired SQL through validate_and_rewrite_readonly_sql.
How do I handle high-cardinality string filters (like WHERE city = 'Bengaluru' vs 'Bangalore')?
Index distinct categorical values of frequently filtered columns using trigram similarity (pg_trgm) or vector embeddings so the agent resolves the exact database string literal before generating the WHERE clause.
Can SQLGlot translate queries if my LLM writes Postgres syntax for a SQLite or MySQL database?
Yes! Calling sqlglot.transpile(sql, read="postgres", write="sqlite")[0] automatically converts dialect-specific date arithmetic, type casts, and identifier quotes.
Is Vanna AI still maintained in 2026?
Per its official GitHub repository (vanna-ai/vanna (https://github.com/vanna-ai/vanna)), the repository was archived by its owner on March 29, 2026 and is now read-only. New projects should build directly on actively maintained primitives like SQLGlot (https://github.com/tobymao/sqlglot), LangGraph (https://github.com/langchain-ai/langgraph), and SQLAlchemy (https://www.google.com/search?q=https://github.com/sqlalchemy/sqlalchemy).
How do I enforce multi-tenant data isolation in a SaaS AI SQL Agent?
Use PostgreSQL Row-Level Security (CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant_id')::uuid)) and set SET LOCAL app.current_tenant_id = '...' from your authenticated FastAPI session before executing the query.
Which open-weight local model is best for Text-to-SQL?
Qwen2.5-Coder-32B-Instruct (or the 14B variant on 16GB GPUs) via Ollama (https://github.com/ollama/ollama) delivers outstanding multi-table join accuracy and adheres strictly to schema constraints.
Frequently asked questions
Why is regex checking (`sql.startswith("SELECT")`) insufficient for SQL agent security?
Attackers or hallucinating LLMs can execute data-modifying statements inside a SELECT using PostgreSQL writable CTEs (WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM del) or stacked queries (SELECT 1; DROP TABLE users). Only AST traversal via SQLGlot (https://github.com/tobymao/sqlglot) combined with a read-only database user blocks these vectors reliably.
What happens if the LLM writes a column name that doesn't exist?
Wrap execute_safe_sqlite_query in a 2-attempt self-correction loop: catch OperationalError / ProgrammingError, pass the exact database error message (no such column: o.total_price) and the table DDL back to the LLM, and re-validate the repaired SQL through validate_and_rewrite_readonly_sql.
How do I handle high-cardinality string filters (like `WHERE city = 'Bengaluru'` vs `'Bangalore'`)?
Index distinct categorical values of frequently filtered columns using trigram similarity (pg_trgm) or vector embeddings so the agent resolves the exact database string literal before generating the WHERE clause.