SQL Agents: Querying Databases in Natural Language
A colleague emails Julie: "Quick question — which artist has the most tracks in our catalog?" Julie could ping the data team and wait. Or, if Aria can query the company database directly, she could just... answer it. Right now.
Beacon & Co. — the music licensing company Julie works for — keeps its catalog in a real database: artists, albums, tracks, customers, the works. In this article, Aria learns to write and run her own SQL queries against it, using nothing more than the same tool-calling pattern from Article 2, applied to a real, structured data source for the first time.
🟡 Skill level: Intermediate.
Quick Reference
When to use this: Whenever an agent needs to answer questions grounded in structured data that already lives in a real database, rather than free-text search.
Basic syntax:
from langchain_community.utilities import SQLDatabase
from langchain.tools import tool
db = SQLDatabase.from_uri("sqlite:///chinook.db")
@tool
def sql_query(query: str) -> str:
"""Run a SQL query against the catalog database."""
try:
return db.run(query)
except Exception as e:
return f"Error: {e}"
agent = create_agent(model="gpt-5-nano", tools=[sql_query])
Common patterns:
SQLDatabase.from_uri(...)connects to a database using a connection string- The agent writes its own SQL, based on your natural-language question
- Errors should be returned as text, not raised as exceptions — so the agent can see what went wrong and try again
Gotchas:
- ⚠️ The agent can write any SQL it wants by default — including
UPDATEorDELETEstatements. Never point a SQL tool at a database connection with write access unless you genuinely intend that. - ⚠️ A model occasionally writes syntactically valid but logically wrong SQL — always sanity-check results that matter.
See also: Tool Calling: Giving Agents Abilities
What You Need to Know First
- Everything from Article 2 — tool definition and how an agent decides to use one
- Basic familiarity with what SQL is (a language for querying databases) is helpful but not required — we'll keep the SQL itself simple and let the agent do the writing
What We'll Cover in This Article
- How to connect to a real SQLite database
- How to wrap SQL execution as a tool, with proper error handling
- How to let an agent write its own SQL from a natural-language question
- How to inspect exactly what SQL the agent generated
What We'll Explain Along the Way
- What a database connection string (URI) is
- Why tool errors should usually be returned as text, not raised as exceptions
Getting a Sample Database
We'll use Chinook, a small, free, widely-used sample database modeling a digital media store — artists, albums, tracks, customers, invoices. It's a reasonable stand-in for the kind of catalog database a company like Beacon & Co. might actually run.
Download it from the official Chinook database repository (the SQLite version, Chinook_Sqlite.sqlite or similar), and place it in your project folder. We'll refer to it as chinook.db from here on.
Connecting to the Database
LangChain's SQLDatabase utility wraps a database connection using a connection URI — a single string describing how and where to connect. For SQLite, that's just a file path:
# Purpose: Connect to the catalog database
# Context: SQLDatabase is a LangChain utility wrapping a real database connection
# Input: A connection URI pointing to our SQLite file
# Output: A SQLDatabase instance we can run queries through
from dotenv import load_dotenv
load_dotenv()
from langchain_community.utilities import SQLDatabase
db = SQLDatabase.from_uri("sqlite:///chinook.db")
The sqlite:/// prefix tells SQLDatabase what kind of database this is (SQLite) and that what follows is a file path. Other database types (PostgreSQL, MySQL) use a similar but different URI format — we're sticking with SQLite here since it needs no separate server running.
Wrapping SQL as a Tool
Now let's wrap query execution as a tool, the same @tool pattern from Article 2 — with one detail that matters more here than it has in earlier articles: how we handle errors.
# Purpose: Let the agent run SQL queries against the catalog database
# Context: Same @tool pattern as every previous tool — only the body differs
# Input: A SQL query string, written by the agent itself
# Output: Either the query results, or a readable error message
from langchain.tools import tool
@tool
def sql_query(query: str) -> str:
"""Run a SQL query against the catalog database to answer questions
about artists, albums, tracks, customers, or invoices."""
try:
return db.run(query)
except Exception as e:
return f"Error: {e}"
That try/except is doing something important. If the agent writes invalid SQL — a typo in a column name, a malformed clause — db.run(query) will raise an exception. Instead of letting that exception crash your program, we catch it and return the error message as the tool's result, just like any other text. The agent sees that error the same way it'd see real query results, and can use it to write a corrected query on the next attempt — a kind of self-correction loop that only works because the failure came back as information instead of crashing everything.
Letting Aria Write Her Own SQL
Let's wire this into Aria and ask the kind of question her colleague might actually send:
# Purpose: Let Aria translate a natural-language question into real SQL
# Context: Continues the running scenario — a colleague's catalog question
# Input: A plain-English question about the catalog
# Output: A real answer, generated from a real SQL query the agent wrote itself
from langchain.agents import create_agent
from langchain.messages import HumanMessage
agent = create_agent(
model="gpt-5-nano",
tools=[sql_query],
)
question = HumanMessage(
content="Which artist in our catalog has the most tracks?"
)
response = agent.invoke({"messages": [question]})
Before printing the answer — take a moment and predict what the actual SQL query might look like. Think about what tables are probably involved (artists, tracks — maybe a table connecting them), and roughly what kind of query would count tracks per artist and find the largest count.
...
Let's check what was actually generated, and compare it to your prediction.
Inspecting the Generated SQL
Rather than guessing which message index holds the tool call (which can shift depending on how many steps the agent took), let's write a small, robust way to find it:
# Purpose: Reliably find and inspect the SQL query the agent generated
# Context: More robust than assuming a fixed message index, which can vary
# Input: The response from the previous call
# Output: The exact SQL query string the agent wrote
for message in response["messages"]:
# AIMessage objects with tool_calls are the ones that requested a tool
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
for call in tool_calls:
print(f"Tool called: {call['name']}")
print(f"SQL query: {call['args']['query']}")
print(response["messages"][-1].content)
You'll likely see something like a JOIN between an artists table and a tracks table, grouped and counted by artist, sorted to find the maximum — whether or not your prediction matched exactly, you should be able to see why the agent wrote what it wrote, just from reading the SQL itself. That's a real, working query, generated entirely from your plain-English question.
Common Misconceptions
❌ Misconception: A SQL agent only ever reads data
Reality: By default, nothing stops the agent from writing UPDATE, DELETE, or INSERT statements if it decides that's the right SQL for a given request — db.run(query) will execute whatever valid SQL it's given.
Why this matters: Never connect a SQL tool to a database using credentials with write access unless you specifically intend for the agent to be able to modify data. For read-only use cases — like Aria looking things up — connect using a database user or connection that only has read permissions, enforced at the database level, not just by hoping the agent won't write anything destructive.
Example:
# ❌ Risky: a connection with full read/write access, for a read-only use case
db = SQLDatabase.from_uri("postgresql://app_user:pass@host/db") # app_user can write
# ✅ Safer: a connection limited to a read-only database user
db = SQLDatabase.from_uri("postgresql://readonly_user:pass@host/db")
❌ Misconception: SQL written by the agent is always correct
Reality: A model can write SQL that's syntactically valid but logically wrong — joining the wrong tables, miscounting, or misunderstanding what a question actually asked.
Why this matters: For anything where the answer actually matters (a report, a decision, anything client-facing), treat agent-generated SQL the way you'd treat a junior analyst's first draft query — worth a sanity check, not blind trust.
Troubleshooting Common Issues
Problem: The agent's query keeps failing with column or table name errors
Symptoms: Repeated "no such column" or "no such table" errors in the tool's returned text.
Common Causes:
- The agent is guessing at table/column names rather than knowing the actual schema (most common, especially on unfamiliar databases)
- Table or column names are case-sensitive or have unusual naming (e.g.,
TrackIdvstrack_id) depending on the database
Diagnostic Steps:
# Step 1: Check what tables actually exist
print(db.get_usable_table_names())
# Step 2: Check a specific table's schema
print(db.get_table_info(["tracks"]))
Solution: If the agent consistently struggles with schema details, consider adding a second tool that exposes db.get_table_info(...), so the agent can check the real schema before writing a query, instead of guessing.
Prevention: For an unfamiliar or complex database, mention key table names directly in the system prompt to reduce early guessing.
Problem: The query technically succeeds but the answer looks wrong
Symptoms: No error, but the result doesn't match what you'd expect from the data.
Common Causes:
- A
JOINmatched more or fewer rows than intended (e.g., counting duplicate rows from an unintended join) - The question itself was ambiguous, and the agent picked a reasonable but not-quite-right interpretation
Solution: Ask Aria to also show the SQL it used (as we did above) so you can read and verify the logic directly, rather than trusting the final answer alone.
Check Your Understanding
Quick Quiz
-
Why does
sql_querycatch exceptions and return them as text, instead of letting them raise normally?Show Answer
So the agent can see the actual error message as part of its conversation and use that information to write a corrected query on a later attempt — a crashing exception would just stop the whole program instead of giving the agent a chance to self-correct.
-
What's the actual security risk of giving an agent a SQL tool?
Show Answer
By default, the agent can write any valid SQL, including statements that modify or delete data, not just read it. The fix isn't trusting the agent to behave — it's connecting through a database user that only has read permissions at the database level, for read-only use cases.
-
If you can't predict the exact message index where a tool call will appear, what's a more reliable way to find it?
Show Answer
Loop through
response["messages"]and check each message for a non-emptytool_callsattribute, rather than assuming a fixed index — the number of steps an agent takes can vary between runs.
Hands-On Exercise
Challenge: Add a second tool, list_tables, that returns db.get_usable_table_names(), and update Aria's system prompt to tell her to check the schema first if she's unsure about table names.
Show Solution
from langchain.tools import tool
@tool
def list_tables() -> str:
"""List all tables available in the catalog database."""
return ", ".join(db.get_usable_table_names())
aria_system_prompt = """
You are Aria, a personal email assistant for Julie at Beacon & Co.
If you're unsure what tables exist before writing a SQL query,
use list_tables first to check.
"""
agent = create_agent(
model="gpt-5-nano",
system_prompt=aria_system_prompt,
tools=[sql_query, list_tables],
)
Explanation: Giving the agent an explicit way to check the schema before guessing reduces the kind of trial-and-error failures covered in the troubleshooting section above.
Summary: Key Takeaways
SQLDatabase.from_uri(...)connects to a real database using a connection URI- Wrapping SQL execution as a tool follows the exact same
@toolpattern from Article 2 - Return errors as text, don't raise them — this lets the agent see what went wrong and self-correct
- The agent writes its own SQL from a natural-language question — always inspect
tool_callsto verify what was actually run - Never connect a SQL tool to a database with write access unless you specifically need the agent to modify data
- Aria can now answer real questions grounded in Beacon & Co.'s actual catalog data
Version Information
Tested with:
- Python:
>=3.10, <4.0 langchain:>=1.1.3(latest stable as of writing:1.3.4)langchain-community:>=0.4.1—SQLDatabaselives here, a new package for this article
Known issues:
- None specific to this article's functionality at the time of writing.
What's Next?
You now understand how to let an agent query a real structured database using natural language, and how to handle errors so it can recover from its own mistakes.
The natural next step is RAG Agents: Retrieval-Augmented Answers — not every source of truth is a database. That article covers giving Aria the ability to search unstructured documents, like Beacon & Co.'s employee handbook, the same way she now searches structured data.
References
- LangChain Academy: Introduction to LangChain (Python) — this section is inspired by and adapted from this course
- LangChain Docs: SQL Agents — official guide to building agents that query databases
- Chinook Sample Database — the sample database used in this article
langchain-communityon PyPI — latest version and release history