Agent-to-Database: Secure Natural Language Interfaces
Build agents that query databases using natural language. Learn to create secure text-to-SQL interfaces with PostgreSQL, validate queries, and prevent SQL injection in LLM-generated queries.
Published on • September 6, 2026
AI Assistant

Agents that can query databases unlock enormous value—but they also introduce serious security risks. Agent-to-database interfaces translate natural language into SQL, execute queries, and return structured results, all while maintaining strict security boundaries.
This guide covers building secure text-to-SQL interfaces with PostgreSQL, including query validation, SQL injection prevention, and production deployment patterns.
The Text-to-SQL Pipeline
A production text-to-SQL system follows this flow:
User Query → Schema Context → LLM → SQL Generation → Validation → Execution → Results
↓
Query Planning
(excludes dangerous operations)
Key Components
- Schema Context: Database structure available to the LLM
- Query Generation: LLM produces SQL from natural language
- Validation Layer: Security checks before execution
- Safe Execution: Sandboxed query execution
- Result Formatting: Structured output for the agent
Building the Pipeline
Step 1: Schema Context
Provide the LLM with relevant schema information:
def get_schema_context(tables: list[str]) -> str:
"""Generate schema context for the LLM."""
context = []
for table in tables:
columns = get_columns(table)
context.append(f"""
Table: {table}
Columns: {', '.join(f'{col.name} ({col.type})' for col in columns)}
Description: {get_table_comment(table)}
""")
return '\n'.join(context)
Step 2: SQL Generation
Use a structured prompt to generate SQL:
SQL_GENERATION_PROMPT = """You are a SQL expert. Generate a PostgreSQL query for the user's question.
Available schema:
{schema_context}
Rules:
- Only use SELECT queries (no INSERT, UPDATE, DELETE, DROP)
- Use parameterized queries for user inputs
- Include appropriate WHERE clauses for filtering
- Use JOINs to combine related tables
- Return at most 1000 rows unless specified
User question: {question}
Generate a SQL query:"""
Step 3: Query Validation
Validate generated SQL before execution:
import sqlparse
from sqlparse.sql import Statement
from sqlparse.tokens import Keyword, DML
ALLOWED_KEYWORDS = {
'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT', 'RIGHT', 'INNER',
'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS',
'NULL', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
'AS', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'EXISTS',
}
BLOCKED_KEYWORDS = {
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE',
'TRUNCATE', 'GRANT', 'REVOKE', 'EXEC', 'EXECUTE',
}
def validate_sql(query: str) -> tuple[bool, str]:
"""Validate a SQL query for safety."""
parsed = sqlparse.parse(query)[0]
# Check for blocked operations
for token in parsed.tokens:
if token.ttype is Keyword and token.value.upper() in BLOCKED_KEYWORDS:
return False, f"Blocked keyword: {token.value}"
# Verify it's a SELECT query
if not any(t.ttype is DML and t.value.upper() == 'SELECT'
for t in parsed.tokens):
return False, "Only SELECT queries are allowed"
# Check for semicolons (prevent multiple statements)
if ';' in query:
return False, "Multiple statements not allowed"
return True, "Valid"
Step 4: Parameterized Execution
Never concatenate user input into SQL:
import asyncpg
async def execute_query(query: str, params: dict) -> list[dict]:
"""Execute a validated SQL query with parameters."""
is_valid, error = validate_sql(query)
if not is_valid:
raise ValueError(f"Invalid query: {error}")
# Use parameterized queries
async with pool.acquire() as conn:
rows = await conn.fetch(query, *params.values())
return [dict(row) for row in rows]
Security Patterns
Query Sandboxing
Execute queries in a restricted database role:
-- Create a read-only role for agent queries
CREATE ROLE agent_reader WITH LOGIN;
GRANT CONNECT ON DATABASE mydb TO agent_reader;
GRANT USAGE ON SCHEMA public TO agent_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO agent_reader;
-- Revoke dangerous permissions
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM agent_reader;
Query Limits
Enforce resource limits:
async def execute_with_limits(query: str, max_rows: int = 1000):
"""Execute query with row limits and timeouts."""
# Add LIMIT clause if not present
if 'LIMIT' not in query.upper():
query = f"{query.rstrip(';')} LIMIT {max_rows}"
# Set query timeout
await conn.execute("SET statement_timeout = '5s'")
return await conn.fetch(query)
Audit Logging
Log all queries for compliance:
import logging
from datetime import datetime
audit_logger = logging.getLogger("agent.database")
async def execute_with_audit(query: str, user_id: str):
"""Execute query with full audit trail."""
start_time = datetime.now()
try:
result = await execute_query(query)
audit_logger.info(
f"Query executed | user={user_id} | "
f"duration={datetime.now() - start_time} | "
f"rows={len(result)} | query={query}"
)
return result
except Exception as e:
audit_logger.error(
f"Query failed | user={user_id} | "
f"error={e} | query={query}"
)
raise
Schema Discovery
Let agents explore database structure safely:
@function_tool
def list_tables() -> list[str]:
"""List all accessible tables in the database."""
return ["users", "orders", "products"]
@function_tool
def describe_table(table_name: str) -> dict:
"""Get the schema for a specific table."""
columns = get_columns(table_name)
return {
"table": table_name,
"columns": [
{"name": col.name, "type": col.type, "nullable": col.nullable}
for col in columns
]
}
Error Handling
Gracefully handle query failures:
class QueryError(Exception):
def __init__(self, message: str, query: str = None):
self.message = message
self.query = query
super().__init__(message)
async def safe_query(query: str) -> dict:
"""Execute query with comprehensive error handling."""
try:
is_valid, error = validate_sql(query)
if not is_valid:
return {"error": f"Query validation failed: {error}"}
result = await execute_query(query)
return {"data": result, "row_count": len(result)}
except asyncpg.exceptions.UndefinedTableError:
return {"error": "Table not found. Check available tables."}
except asyncpg.exceptions.DataError as e:
return {"error": f"Invalid data in query: {e}"}
except asyncpg.exceptions.PostgresError as e:
return {"error": f"Database error: {e}"}
except Exception as e:
return {"error": f"Unexpected error: {e}"}
Production Deployment
Connection Pooling
import asyncpg
pool = None
async def init_pool():
global pool
pool = await asyncpg.create_pool(
min_size=5,
max_size=20,
command_timeout=10,
# Use read-only connection
server_settings={"default_transaction_isolation": "read committed"},
)
Rate Limiting
from collections import defaultdict
import time
class QueryRateLimiter:
def __init__(self, max_queries_per_minute: int = 30):
self.max_queries = max_queries_per_minute
self.queries: dict[str, list[float]] = defaultdict(list)
def can_query(self, user_id: str) -> bool:
now = time.time()
cutoff = now - 60
self.queries[user_id] = [t for t in self.queries[user_id] if t > cutoff]
return len(self.queries[user_id]) < self.max_queries
def record_query(self, user_id: str):
self.queries[user_id].append(time.time())
Best Practices
- Never trust LLM output: Always validate before execution
- Use parameterized queries: Prevent SQL injection
- Limit database permissions: Read-only for agent access
- Log everything: Audit trail for compliance
- Set timeouts: Prevent runaway queries
- Rate limit: Control query volume
- Test with production data: Validate against real schemas
- Monitor query performance: Track slow queries
Next Steps
- Explore Database Indexing for query performance
- Read about Structured Outputs for result validation
- Learn about Agent-to-Database Security in the PostgreSQL docs
Building secure text-to-SQL interfaces requires careful attention to validation, permissions, and monitoring. By implementing these patterns, you can give agents safe access to your data while maintaining security boundaries.