teachyou.ai academy
← All posts
LangChain

LangChain for SQL: Building a Database Question-Answering Chain

Pramod Dutta · Jun 27, 2026 · 13 min read

Why Everyone Wants to Ask Their Database Questions in Plain English

Every company with a Postgres or MySQL instance eventually hits the same wall: the people who need answers from the data cannot write SQL, and the people who can write SQL are busy building features. A sales manager wants to know "which region had the highest churn last quarter," and instead of getting an answer in thirty seconds, they file a ticket, wait two days, and get a Slack message with a screenshot of a spreadsheet.

LangChain's SQL tooling exists to close that gap. Instead of teaching every stakeholder SQL, you teach an LLM your schema, let it draft the query, execute it against a real connection, and return a grounded natural-language answer. This is not a toy demo — with the right guardrails, a LangChain SQL chain is genuinely production-usable for internal analytics tools, support dashboards, and reporting bots.

In this article we will build a database question-answering chain from scratch using create_sql_query_chain, look at the older SQLDatabaseChain for comparison, wire in execution and answer synthesis, and cover the security and reliability practices that separate a demo from something you can actually ship. By the end you'll have working code for a chain that takes a question like "what are the top 5 customers by total order value" and returns both the SQL it ran and a plain-English answer.

Setting Up the Database Connection

LangChain talks to your database through the SQLDatabase utility class, which wraps SQLAlchemy. That means anything SQLAlchemy can connect to — Postgres, MySQL, SQLite, Snowflake, BigQuery via a connector — LangChain can connect to as well.

Let's start with a local SQLite database so the example is runnable without any external setup, then note what changes for Postgres.

from langchain_community.utilities import SQLDatabase

# SQLite for local testing
db = SQLDatabase.from_uri("sqlite:///chinook.db")

print(db.dialect)
print(db.get_usable_table_names())
print(db.get_table_info(["Customer", "Invoice"]))

db.get_table_info() is the piece that matters most. It returns the CREATE TABLE statements plus a few sample rows for each table, and this is exactly what gets injected into the prompt so the LLM knows column names, types, and foreign keys. If your schema is large, you don't want to dump every table into every prompt — we'll deal with that under "Scaling to Large Schemas" below.

For Postgres, the connection string just changes:

from langchain_community.utilities import SQLDatabase

db = SQLDatabase.from_uri(
    "postgresql+psycopg2://readonly_user:password@localhost:5432/analytics"
)

Note the username: readonly_user. This is not a throwaway detail — it's the single most important security decision in this whole article, and we'll come back to it.

The Simple Path: SQLDatabaseChain

The original way LangChain solved this problem was SQLDatabaseChain, which bundles query generation, execution, and answer synthesis into one call. It's still in langchain_experimental (moved out of core because letting an LLM execute arbitrary SQL is, correctly, treated as an experimental/risky pattern).

from langchain_experimental.sql import SQLDatabaseChain
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

db_chain = SQLDatabaseChain.from_llm(
    llm,
    db,
    verbose=True,
    return_intermediate_steps=True,
)

result = db_chain.invoke(
    {"query": "How many customers are there from Brazil?"}
)

print(result["result"])

With verbose=True you'll see the full trace: the generated SQL, the raw rows returned, and the final natural-language answer. That transparency is valuable while you're developing, but SQLDatabaseChain has three real problems once you try to ship it:

  • It executes the query automatically, with no hook for a human or a rules-based check to approve it first.
  • Its default prompt does not stop the model from writing INSERT, UPDATE, or DELETE statements — it just asks nicely.
  • It gives you limited control over what happens between "SQL generated" and "SQL executed," which is exactly where you want to add validation.

That's why LangChain now recommends create_sql_query_chain for anything beyond a quick prototype: it separates query generation from execution, so you control the dangerous part explicitly.

The Recommended Path: create_sql_query_chain

create_sql_query_chain builds a chain that takes a question and the database schema and returns *only* the generated SQL string — it does not run it. You then decide how and whether to execute it.

from langchain.chains import create_sql_query_chain
from langchain_openai import ChatOpenAI
from langchain_community.utilities import SQLDatabase

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
db = SQLDatabase.from_uri("sqlite:///chinook.db")

query_chain = create_sql_query_chain(llm, db)

sql_query = query_chain.invoke(
    {"question": "What are the top 5 customers by total invoice amount?"}
)

print(sql_query)

A typical output looks like this:

SELECT "Customer"."FirstName", "Customer"."LastName", SUM("Invoice"."Total") AS "TotalSpent"
FROM "Customer"
JOIN "Invoice" ON "Customer"."CustomerId" = "Invoice"."CustomerId"
GROUP BY "Customer"."CustomerId"
ORDER BY "TotalSpent" DESC
LIMIT 5;

Now you execute it explicitly, using QuerySQLDataBaseTool (or your own execution wrapper):

from langchain_community.tools.sql_database.tool import QuerySQLDataBaseTool

execute_query = QuerySQLDataBaseTool(db=db)
rows = execute_query.invoke(sql_query)
print(rows)

Because generation and execution are two separate steps, you can insert a validation function, a human-approval step, or a read-only check in between — something SQLDatabaseChain doesn't give you cleanly.

Wiring It Into a Full Question-Answering Chain

The real goal isn't "return SQL" — it's "return an answer a human can read." LangChain Expression Language (LCEL) makes it straightforward to chain: generate SQL → execute SQL → feed both back into the LLM to produce a natural-language answer.

from operator import itemgetter

from langchain.chains import create_sql_query_chain
from langchain_community.tools.sql_database.tool import QuerySQLDataBaseTool
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

write_query = create_sql_query_chain(llm, db)
execute_query = QuerySQLDataBaseTool(db=db)

answer_prompt = PromptTemplate.from_template(
    """Given the following user question, the generated SQL query, and the
SQL result, write a clear, concise natural language answer.

Question: {question}
SQL Query: {query}
SQL Result: {result}
Answer: """
)

answer_chain = answer_prompt | llm | StrOutputParser()

full_chain = (
    RunnablePassthrough.assign(query=write_query).assign(
        result=itemgetter("query") | execute_query
    )
    | answer_chain
)

response = full_chain.invoke(
    {"question": "Which genre generated the most total revenue?"}
)

print(response)

Walk through what's happening in full_chain:

  1. RunnablePassthrough.assign(query=write_query) takes the incoming {"question": ...} dict and adds a query key holding the generated SQL, while keeping question intact.
  2. .assign(result=itemgetter("query") | execute_query) pulls that query key back out, runs it through the database, and stores the rows under result.
  3. The dict now has question, query, and result — exactly the three variables answer_prompt needs.
  4. answer_chain formats the prompt and asks the LLM to turn raw rows into an English sentence.

This is the actual shape of a database Q&A chain you'd put behind an internal Slack bot or a support-facing "ask your data" widget. Run it a few times with different questions and you'll notice the model handles joins, aggregations, and even simple date filtering reasonably well as long as the schema and sample rows are visible in the prompt.

Letting an Agent Self-Correct with SQL Tools

A single-shot chain generates one query and hopes for the best. If the LLM misreads a column name, you get a SQL error and no recovery. LangChain also ships a prebuilt SQL agent that can inspect the schema, run a query, see the error, and retry — much closer to how a human analyst actually works.

from langchain_community.agent_toolkits import SQLDatabaseToolkit, create_sql_agent
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
toolkit = SQLDatabaseToolkit(db=db, llm=llm)

agent_executor = create_sql_agent(
    llm=llm,
    toolkit=toolkit,
    agent_type="tool-calling",
    verbose=True,
)

result = agent_executor.invoke(
    {"input": "What percentage of invoices came from customers in the USA?"}
)

print(result["output"])

The toolkit gives the agent four tools: list tables, get schema for specific tables, run a query, and a query-checker tool that asks the LLM to review its own SQL before execution. When the agent writes a query with a typo'd column name, the database throws an error, that error text gets fed back to the agent as an observation, and it revises the query — usually within one or two retries.

The tradeoff is cost and latency: an agent makes multiple LLM calls per question instead of one, and verbose=True will show you exactly how many round-trips it takes. For a low-traffic internal tool, that's a fine trade for the extra reliability. For a high-traffic customer-facing feature, you'll likely want the simpler create_sql_query_chain plus your own retry logic so you can cap costs.

Handling Schemas Too Large for One Prompt

db.get_table_info() dumping every table into the prompt works fine for a 10-table demo database. It falls apart at 200 tables — you'll blow past context limits and the model will get worse at picking the right table, not better, because it's drowning in irrelevant schema.

The fix is a two-stage retrieval pattern: first ask the LLM (or a cheap embedding similarity search) which tables are actually relevant to the question, then only inject those tables' schema into the query-generation prompt.

from langchain_core.output_parsers import CommaSeparatedListOutputParser
from langchain_core.prompts import ChatPromptTemplate

table_names = "\n".join(db.get_usable_table_names())

table_prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "You are given a question and a list of database table names. "
            "Return a comma-separated list of the table names that are "
            "relevant to answering the question. Tables:\n" + table_names,
        ),
        ("human", "{question}"),
    ]
)

table_chain = table_prompt | llm | CommaSeparatedListOutputParser()

relevant_tables = table_chain.invoke(
    {"question": "What are the top 5 customers by total invoice amount?"}
)
print(relevant_tables)  # e.g. ['Customer', 'Invoice']

# Now build the query chain scoped to only those tables
scoped_query_chain = create_sql_query_chain(
    llm, db, k=5
).with_config({"configurable": {"tables": relevant_tables}})

In practice, many teams implement table selection with a lightweight vector store: embed each table's name plus a short description, embed the question, and retrieve the top-k tables by cosine similarity. That avoids an extra LLM call for table selection and scales cleanly to schemas with hundreds of tables. Either approach — LLM-based selection or embedding retrieval — accomplishes the same goal: keep the schema context tight and relevant so create_sql_query_chain has a fighting chance of writing a correct join.

Security: The Part You Cannot Skip

If you remember one thing from this article, make it this section. Letting an LLM generate SQL that runs against your real database is a prompt-injection and data-exfiltration risk if you don't constrain it. A malicious or simply confused user could ask a question engineered to make the model emit DROP TABLE customers; or a query that dumps every row of a users table including password hashes.

Concrete practices that matter:

  • Use a read-only database role for the connection. This is non-negotiable. Create a Postgres/MySQL user with SELECT-only grants on the specific schemas the chain needs, and use that connection string in SQLDatabase.from_uri(). Even if the LLM generates a destructive statement, the database itself will reject it.
  • Never point the chain at a connection with write, DDL, or admin privileges, no matter how good your prompt guardrails look in testing. Prompts are not a security boundary; database permissions are.
  • Set `include_tables` explicitly on SQLDatabase.from_uri(..., include_tables=["orders", "customers"]) so the model never even sees tables it has no business querying, like an admin_users or api_keys table sitting in the same database.
  • Add a query-checker step before execution. LangChain's QuerySQLCheckerTool asks the LLM to double-check its own SQL for common mistakes (and you can extend this with a regex-based blocklist for keywords like DROP, DELETE, UPDATE, ALTER, TRUNCATE, GRANT).
  • Cap row limits. Force a LIMIT in generated queries (LangChain's default prompts already push toward this, but validate it yourself before execution) so a broad question can't return or process millions of rows.
  • Log every generated query with the originating question and user ID. When something goes wrong, or when you're auditing what the LLM has been asking your database to do, you want that trail.

A minimal validation wrapper before execution looks like this:

import re

BLOCKED_KEYWORDS = ("DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "TRUNCATE", "GRANT")

def validate_sql(query: str) -> str:
    upper = query.upper()
    if any(keyword in upper for keyword in BLOCKED_KEYWORDS):
        raise ValueError(f"Blocked potentially destructive SQL: {query}")
    if "LIMIT" not in upper:
        query = query.rstrip(";") + " LIMIT 200;"
    return query

sql_query = query_chain.invoke({"question": "Show me all customer emails"})
safe_query = validate_sql(sql_query)
rows = execute_query.invoke(safe_query)

This is a defense-in-depth layer on top of the read-only database role, not a replacement for it. Both should be in place at the same time.

Improving Accuracy with Few-Shot Examples

Out of the box, create_sql_query_chain uses a generic prompt with your schema. If your database has domain-specific quirks — a status column that stores integers mapped to meanings elsewhere, or a naming convention like fct_ and dim_ prefixes from a dbt project — the model benefits enormously from a handful of example question-to-SQL pairs.

from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate
from langchain_community.utilities import SQLDatabase

examples = [
    {
        "input": "How many active customers do we have?",
        "query": 'SELECT COUNT(*) FROM "Customer" WHERE "Status" = 1;',
    },
    {
        "input": "List orders placed in the last 30 days",
        "query": (
            'SELECT * FROM "Invoice" WHERE "InvoiceDate" >= '
            "date('now', '-30 days');"
        ),
    },
]

example_prompt = PromptTemplate.from_template(
    "Question: {input}\nSQL Query: {query}"
)

few_shot_prompt = FewShotPromptTemplate(
    examples=examples,
    example_prompt=example_prompt,
    prefix=(
        "You are a SQLite expert. Given a question, write a syntactically "
        "correct SQLite query. Here are some examples of how the schema's "
        "conventions map to queries:"
    ),
    suffix="Question: {input}\nSQL Query:",
    input_variables=["input"],
)

query_chain = create_sql_query_chain(
    llm, db, prompt=few_shot_prompt
)

Even three or four well-chosen examples — especially ones that show how your status codes, soft-delete flags, or timezone conventions work — noticeably reduce the "technically valid SQL, wrong business logic" failure mode, which is far more common in practice than outright syntax errors.

Testing and Evaluating Your SQL Chain

Because the failure mode here is silent — a query can run successfully and return a confidently wrong answer — you need an evaluation set, not just spot-checking during development. Build a small labeled dataset of question/expected-SQL (or question/expected-answer) pairs drawn from real questions your users actually ask, and re-run it every time you touch the prompt, the model, or the schema description.

test_cases = [
    {
        "question": "How many customers are there from Brazil?",
        "expected_answer_contains": "5",
    },
    {
        "question": "What is the most popular music genre by number of tracks sold?",
        "expected_answer_contains": "Rock",
    },
]

def run_eval(chain, cases):
    results = []
    for case in cases:
        output = chain.invoke({"question": case["question"]})
        passed = case["expected_answer_contains"].lower() in output.lower()
        results.append({"question": case["question"], "passed": passed, "output": output})
    return results

for r in run_eval(full_chain, test_cases):
    status = "PASS" if r["passed"] else "FAIL"
    print(f"[{status}] {r['question']} -> {r['output']}")

Track this pass rate over time the same way you'd track test coverage. When you upgrade the underlying model, change the few-shot examples, or add new tables to the schema, rerun the eval set before shipping — it's the difference between catching a regression in CI and catching it when a VP gets a wrong revenue number in production.

Wrapping Up

A LangChain SQL chain is one of the highest-leverage things you can build with LLMs today because the payoff is immediate and measurable: fewer ad-hoc query requests, faster answers, and a self-service layer over data that used to require a SQL-literate person in the loop. The pattern that actually works in production is not the one-line SQLDatabaseChain demo — it's create_sql_query_chain for generation, an explicit execution step you control, a read-only database role, a validation layer that blocks destructive statements, few-shot examples tuned to your schema's quirks, and an evaluation set you rerun on every change.

Start small: point this at one read-only replica, scope it to three or four tables your team asks about most, and get the guardrails right before you expand table coverage or open it up to more users. The scaffolding in this article — connection setup, query chain, execution, answer synthesis, security layer, and evaluation harness — is enough to take a genuinely useful internal tool from prototype to something people rely on daily.

If you want to go deeper — agentic retries, LangGraph-based multi-step SQL reasoning, hybrid search over table metadata for schemas with hundreds of tables, and full production deployment patterns — that's exactly what we cover hands-on in the LangChain Tutorial 2026 course here on TeachYou.ai, where we build a complete database Q&A system end to end alongside everything else in the LangChain ecosystem.