user-facing failures without requiring manual intervention.
Implementation (TypeScript)
The following implementation demonstrates a production-grade pipeline using OpenAI's structured output API, PostgreSQL connection pooling, and a deterministic validation layer.
import { OpenAI } from "openai";
import { Pool, PoolClient } from "pg";
import { z } from "zod";
// 1. Configuration & Connection Management
const dbPool = new Pool({
host: process.env.DB_HOST || "localhost",
port: Number(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME || "analytics",
user: process.env.DB_USER || "readonly_user",
password: process.env.DB_PASSWORD,
max: 10,
idleTimeoutMillis: 30000,
});
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// 2. Schema Context Assembly
const SCHEMA_CONTEXT = `
Target Dialect: PostgreSQL 15
Tables:
- employees (id UUID PK, full_name TEXT, department_id UUID FK, salary DECIMAL, hire_date DATE)
- departments (id UUID PK, name TEXT, budget DECIMAL, region TEXT)
- projects (id UUID PK, title TEXT, department_id UUID FK, status TEXT, start_date DATE)
Constraints: All monetary values are in USD. Dates follow ISO 8601. Use standard PostgreSQL functions.
`;
// 3. Structured Output Schema
const SqlGenerationSchema = z.object({
sql_query: z.string().describe("The generated SQL query"),
confidence: z.number().min(0).max(1).describe("Model confidence score"),
reasoning: z.string().describe("Brief explanation of table/column selection"),
});
type SqlGeneration = z.infer<typeof SqlGenerationSchema>;
// 4. Deterministic Validation Layer
function validateQuery(query: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const upperQuery = query.toUpperCase().trim();
if (!upperQuery.startsWith("SELECT")) {
errors.push("Only SELECT statements are permitted.");
}
if (/\b(DROP|DELETE|UPDATE|INSERT|ALTER|TRUNCATE)\b/.test(upperQuery)) {
errors.push("Destructive or modification commands are blocked.");
}
if (upperQuery.includes("PG_SLEEP") || upperQuery.includes("EXEC")) {
errors.push("Potential injection or system commands detected.");
}
return { valid: errors.length === 0, errors };
}
// 5. Core Generation & Execution Pipeline
async function generateAndExecute(
userQuestion: string,
maxRetries: number = 2
): Promise<{ data: any[]; metadata: { tokens: number; attempts: number } }> {
let currentQuery = "";
let attempts = 0;
let totalTokens = 0;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
attempts = attempt;
const prompt = attempt === 1
? `Convert the following question into a PostgreSQL query.\nSchema:\n${SCHEMA_CONTEXT}\nQuestion: ${userQuestion}`
: `Previous query failed with error: "${currentQuery}". Fix it and return a valid SELECT statement.\nSchema:\n${SCHEMA_CONTEXT}`;
const response = await openai.beta.chat.completions.parse({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_schema", json_schema: { name: "sql_gen", schema: SqlGenerationSchema } },
temperature: 0,
});
const parsed = response.choices[0].message.parsed!;
currentQuery = parsed.sql_query;
totalTokens += response.usage?.total_tokens || 0;
const validation = validateQuery(currentQuery);
if (!validation.valid) {
currentQuery = `Validation Error: ${validation.errors.join(", ")}`;
continue;
}
try {
const client: PoolClient = await dbPool.connect();
const result = await client.query(currentQuery);
client.release();
return { data: result.rows, metadata: { tokens: totalTokens, attempts } };
} catch (dbError: any) {
currentQuery = `Database Error: ${dbError.message}`;
continue;
}
}
throw new Error(`Query generation failed after ${maxRetries} attempts. Last error: ${currentQuery}`);
}
// Usage Example
async function main() {
const question = "Show me the top 3 departments by budget and their average employee salary.";
try {
const result = await generateAndExecute(question);
console.log("Execution successful:", result.data);
console.log("Metadata:", result.metadata);
} catch (err) {
console.error("Pipeline failed:", err);
}
}
Why these choices matter:
zod + OpenAI's json_schema response format guarantees parseable output, eliminating regex-based extraction failures.
- Connection pooling with
max: 10 prevents database connection exhaustion during concurrent agent retries.
- The validation layer runs before execution, blocking destructive commands at the application boundary.
- Error feedback loops pass exact database or validation errors back to the model, enabling contextual correction without human intervention.
Pitfall Guide
1. Schema Drift Ignorance
Explanation: Database schemas evolve. Hardcoded schema strings in prompts become stale, causing the model to reference dropped columns or outdated table names.
Fix: Implement a schema introspection service that queries information_schema or pg_catalog at runtime. Cache the result with a TTL matching your deployment frequency.
2. Unrestricted Database Credentials
Explanation: Using admin or write-enabled credentials for Text-to-SQL pipelines turns a prompt injection or generation bug into a data destruction event.
Fix: Create dedicated readonly database roles with GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user;. Enforce this at the connection string level.
Explanation: Users can append malicious instructions like "; DROP TABLE employees; -- to their natural language queries.
Fix: Never concatenate raw user input into SQL. Use parameterized queries where possible, and run all generated SQL through a strict allowlist validator before execution. Sanitize prompt templates using system/assistant role separation.
4. Dialect Mismatch
Explanation: LLMs trained on mixed SQL dialects may generate MySQL-specific functions (IFNULL, LIMIT) for PostgreSQL, or vice versa.
Fix: Explicitly declare the target dialect in the system prompt. Maintain dialect-specific function mapping tables if your application supports multiple database backends.
5. Token Bloat from Full Schema Dump
Explanation: Injecting entire database schemas (hundreds of tables, thousands of columns) into prompts exhausts context windows and increases costs.
Fix: Implement schema pruning. Use vector search or keyword matching to inject only tables and columns relevant to the user's query. Tools like pgvector or simple TF-IDF scoring can filter context dynamically.
6. Missing Error Context for Retry Loops
Explanation: When a query fails, simply retrying with the same prompt yields identical results. The model needs to know why it failed.
Fix: Pass the exact database error message or validation failure back into the prompt. Structure the retry loop to incrementally refine the query based on concrete feedback.
7. Over-Engineering Simple Queries
Explanation: Routing every query through an agent loop adds unnecessary latency and cost for straightforward requests like SELECT count(*) FROM users.
Fix: Implement a query complexity classifier. Route simple aggregations and single-table lookups to direct generation. Reserve agent fallbacks for multi-table joins, ambiguous business terms, or date range calculations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Internal analytics dashboard with simple schemas | Direct API Call + Validation | Low latency, predictable costs, sufficient accuracy for straightforward queries | Baseline |
| Complex business intelligence with ambiguous terminology | Agent-Driven Loop | Self-correction handles joins, aggregations, and dialect nuances without user intervention | +150-200% tokens |
| Customer-facing data exploration tool | UI-Integrated Pipeline + Fallback | Reduces friction for non-technical users while maintaining execution safety | +50-80% (frontend + API) |
| High-concurrency production environment | Connection Pool + Schema Pruning | Prevents database exhaustion and context window overflow under load | Neutral (infrastructure optimization) |
Configuration Template
// config/pipeline.ts
export const PipelineConfig = {
llm: {
model: "gpt-4o-mini",
temperature: 0,
maxTokens: 512,
structuredOutput: true,
},
database: {
host: process.env.DB_HOST!,
port: 5432,
database: process.env.DB_NAME!,
user: process.env.DB_READONLY_USER!,
password: process.env.DB_READONLY_PASSWORD!,
pool: { max: 10, idleTimeout: 30000 },
},
validation: {
allowedCommands: ["SELECT"],
blockedPatterns: ["DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "TRUNCATE", "PG_SLEEP", "EXEC"],
maxExecutionTimeMs: 5000,
},
retry: {
maxAttempts: 3,
backoffMultiplier: 1.5,
includeErrorContext: true,
},
telemetry: {
enabled: true,
logLevel: "info",
metricsEndpoint: "/metrics",
},
};
Quick Start Guide
- Initialize Database Role: Create a read-only user in your target database. Grant
SELECT privileges on required schemas. Verify connection works with these credentials only.
- Deploy Schema Introspector: Run a script that queries
information_schema.tables and information_schema.columns. Store the output in a JSON file or Redis cache with a 15-minute expiration.
- Configure LLM Client: Set up your OpenAI or compatible API client. Enable structured output parsing. Set temperature to
0 for deterministic generation.
- Run Validation & Execution: Integrate the pre-execution validator. Test with sample queries. Monitor logs for validation blocks and database errors.
- Enable Telemetry: Add request/response logging, token tracking, and latency metrics. Set up alerts for validation failure spikes or execution timeouts.
Text-to-SQL is not a magic bullet. It is a distributed system that requires strict boundaries, deterministic validation, and graceful degradation. When architected correctly, it transforms database access from a developer bottleneck into a self-service capability without compromising security or reliability.