A SQL query agent takes plain English questions, writes SQL, runs it against a real database, and explains the results. No dashboards, no query builders – just a conversation. The trick is giving the LLM the right tools and guardrails so it can explore your schema, execute queries safely, and self-correct when something goes wrong.
Here’s what the full agent looks like. We’ll build it piece by piece.
| |
| |
That gives us a small but realistic e-commerce database with customers, orders, products, and line items. Enough to ask real questions.
Building the Agent Tools
The agent needs three tools: one to inspect the database schema, one to execute queries, and one to format results. Each tool is a plain function with strict guardrails.
| |
The execute_query function does two critical things: it blocks any non-SELECT statement at the token level, and it scans for dangerous keywords that could be embedded in subqueries or CTEs. It also caps results at 100 rows to keep token usage under control.
Wiring Up the Agent Loop
Now we define the tool schemas and the agent loop. The LLM calls get_schema first to understand the database, then writes and executes queries.
| |
The run_sql_agent function returns the conversation history so you can pass it back for follow-up questions. That’s what makes multi-turn work.
Multi-Turn Conversations
The real power shows up when the agent can refine queries across turns. The user asks a vague question, the agent runs a query, and the user drills deeper based on the results.
| |
Each follow-up reuses the conversation history, so the agent remembers context. It knows “that” in the third question refers to Alice’s spending. This is the difference between a query tool and an agent – it maintains state and reasons across turns.
Safety: Preventing Bad Queries
The keyword-blocking approach above catches most attacks, but production systems need more layers. Here’s a stricter validator that also handles sneaky injection patterns.
| |
Key safety measures in this design:
- Read-only enforcement: Both token-level and regex-based checks block write operations
- Multi-statement blocking: Strips string literals first, then checks for semicolons – this defeats
'; DROP TABLE customers; --style injections - CTE support: Allows
WITHclauses since they’re common in analytical queries - PRAGMA blocking: Prevents schema modifications and database attach attacks specific to SQLite
- Result limiting:
fetchmany(100)caps output so aSELECT *on a million-row table doesn’t blow up your context window
For production, also consider running queries on a read-only database replica or using SQLite’s SQLITE_OPEN_READONLY flag:
| |
This gives you defense in depth. Even if a crafted query somehow bypasses the text-level checks, the database connection itself will refuse writes.
Common Errors and Fixes
“Only SELECT queries are allowed” on valid queries. If your query starts with a comment or whitespace, the keyword check fails. Strip and normalize before checking:
| |
LLM generates queries with wrong column names. This happens when the agent skips the schema inspection step. Make the system prompt explicit: “Always call get_schema before writing any query.” You can also enforce this in code by checking if get_schema was called before allowing execute_query.
Token limits hit on large result sets. Cap rows in your execute_query function and tell the agent to use LIMIT clauses. If results are still too large, return only the first few rows with a count: “Showing 10 of 5,432 rows.”
Agent loops endlessly on syntax errors. Set a hard limit on tool calls per turn (we used 10 above). If the agent can’t fix its SQL after 3 attempts, it’s usually better to ask the user to rephrase.
SQLite type mismatches. SQLite is loosely typed, so WHERE created_at > '2026-01-01' works on TEXT dates but produces wrong results with mixed formats. Standardize your date columns or tell the agent the expected format in the system prompt.
Agent tries to join tables that don’t have a relationship. Include foreign key information in the schema tool output. The sql column from sqlite_master includes REFERENCES clauses, which is why we return the raw CREATE TABLE statements.
Related Guides
- How to Build a Tool-Calling Agent with Claude and MCP
- How to Build an API Testing Agent with LLMs and Requests
- How to Build a Customer Support Agent with RAG and Tool Calling
- How to Build a Slack Bot Agent with LLMs and Bolt
- How to Build a Code Generation Agent with LLMs
- How to Build a Log Analysis Agent with LLMs and Regex Tools
- How to Build a Contract Analysis Agent with LLMs and PDF Parsing
- How to Build a Tool-Using AI Agent with Python
- How to Build a Financial Analysis Agent with LLMs and Market Data
- How to Build a Monitoring Agent with Prometheus Alerts and LLM Diagnosis