Switching clouds? Get up to $10K in credits + hands-on help.

Apply now
Databases

Connect my AI agent to a SQL database with LangChain tools

LangChain gives your agent a controlled interface to SQL data

AI agents often need structured data from a relational database to answer questions, generate reports, or decide what to do next. LangChain's SQL database tools give the model a controlled interface to that data, allowing it to write and run queries dynamically inside boundaries you set for security, validation, and result formatting.

This guide covers those connection patterns, the enforcement that has to sit outside the model, and result formatting. Examples use Render Postgres, though the patterns apply to any database reachable through SQLAlchemy. If you haven't built the agent itself yet, start with Building an agent with LangChain and Claude/OpenAI and refer to this article once you have a tool-calling loop to attach a database to.

Prerequisites

Before wiring an agent to a database, make sure your environment includes:

  • Python 3.10 or later with pip package management. LangChain 1.x requires 3.10 as a minimum.
  • LangChain 1.x (langchain for the agent runtime, langchain-community for the SQL toolkit)
  • SQLAlchemy 2.0+ for database connection abstraction
  • Database-specific drivers: psycopg2-binary for Postgres, pymysql for MySQL, or sqlite3 (Python standard library)
  • LangChain LLM integration: langchain-openai, langchain-anthropic, or equivalent for your model provider
  • sqlparse to validate agent-generated SQL before execution
  • pandas for the summary formatter, tenacity for retry handling, and flask for the health check endpoint, all used in later examples

Install core dependencies:

One caveat about that second package. LangChain sunset langchain-community in May 2026, so it sits at 0.4.2 and receives no further fixes. SQLDatabase and SQLDatabaseToolkit have no successor package yet, since langchain-classic only re-exports them, so the imports below are still the current way to do this. Pin the version explicitly:

Pin the rest to exact versions in your own requirements.txt. The frozen dependency is also an argument for the rest of this guide: the control you can rely on is the database grant, not the library.

SQLDatabaseToolkit splits database access into four separate tools

LangChain exposes database access to agents through SQLDatabaseToolkit, which wraps a SQLDatabase connection in four discrete tools the agent can choose from:

  • sql_db_list_tables lists the tables the agent is allowed to see
  • sql_db_schema returns CREATE TABLE statements and sample rows for named tables
  • sql_db_query_checker asks the LLM to review its own query for common mistakes
  • sql_db_query executes a query and returns the results

That decomposition matters. The agent discovers your schema at runtime rather than carrying it in a static prompt, and each step is a separate tool call you can log, validate, or block independently. A single question becomes several round trips: list the tables, fetch the schema for the ones that look relevant, write a query against it, execute, then synthesize an answer from the raw rows.

Assembling the toolkit into an agent looks like this:

The instruction to add a LIMIT clause is a prompt-level hint rather than a guarantee. The next two sections cover the enforcement that sits outside the model.

Grants and validation enforce safety, not the system prompt

The risk here moves from classic SQL injection to the confused deputy problem: your agent holds database credentials the end user does not, so anyone who can shape the agent's context can borrow those privileges. A prompt-injected instruction, whether typed by the user or hidden in a document the agent ingested, becomes a query executed with your agent's grants. For the broader threat model, see Security best practices when building AI agents and What's the best way to implement guardrails against prompt injection?.

Enforcement must live outside the model's reasoning loop, in the grants and in a validation layer.

Give the agent its own read-only role

Start with the grants by giving the agent a dedicated role with SELECT on only the tables it needs:

Three details are worth getting right here.

Although Postgres already grants USAGE on public to everyone by default, keep the line anyway. It becomes essential the moment you move the agent's tables into their own schema, which is helpful for exactly the isolation reasons detailed in this section.

Name tables explicitly rather than using ON ALL TABLES IN SCHEMA public. Both forms apply only to tables that exist at grant time, but the explicit list documents your intent and won't quietly widen in scope if someone re-runs it after adding a table.

Resist the temptation to add ALTER DEFAULT PRIVILEGES ... GRANT SELECT ON TABLES. It makes grants survive migrations, but it does so by granting SELECT on every table you create later, including audit, session, and credential tables the agent should never see. The friction of an explicit grant per new table is the control working as intended.

You may wonder why this uses CREATE USER rather than Render's own user management. A user you add through the Render Dashboard or API becomes your database's new default user, and Render rewrites Blueprint-managed environment variables that reference the connection string to point at it (Database Credentials). That's the behavior you want when rotating your application's credentials, but it's not ideal when adding a second, narrower role alongside the first.

The tradeoff is lifecycle. A role you create with CREATE USER is yours to manage, not Render's: it won't appear in the Render Dashboard or API, and Render's credential rotation won't touch it. Generate the password the way you generate your other secrets, store it as an environment variable, and rotate it on your own schedule.

Validate with a parser, not a keyword denylist

For the validation layer, use a SQL parser as shown below rather than string matching. Substring checks against a keyword denylist fail in both directions: SELECT created_at FROM orders contains the substring CREATE in a column name, while SELECT 1; TRUNCATE orders sails through, because its first statement holds none of the banned keywords.

Hand this subclass to the toolkit in place of SQLDatabase, and every tool call routes through validation. This is the one instance the agent uses. The two overrides serve different callers: run raises, which is what you want when your own code executes a query, and run_no_throw is what sql_db_query calls, where returning the rejection as a string gives the model a chance to rewrite rather than aborting the run.

Treat the parser as a backstop, not a boundary. Postgres allows data-modifying statements inside a CTE, and sqlparse classifies the whole statement by its outer form, so this passes the validator:

You could special-case that pattern, but doing so starts an arms race against a parser that was never meant to be a security boundary. The read-only grant is what actually prevents the write: on a role with only SELECT, that query fails at the database no matter what your validator concludes.

Enforce the row limit in SQL

SQLDatabase offers two knobs that look like result limits and are not. sample_rows_in_table_info (default 3) sets how many sample rows appear in the schema description, and max_string_length (default 300) truncates each individual string value in a query result. Neither caps how many rows a query returns.

To bound row counts, enforce it in SQL. Reject queries with no LIMIT, or wrap the agent's query in an outer limit before execution:

Apply this inside the run override above, passing enforce_row_limit(command) to super().run() instead of command. The ordering matters: wrapping happens after validation, never before, because splicing an unvalidated model-authored string into your subquery makes a missing row cap the least of your problems. Enforcing the limit here rather than in the system prompt is what makes it durable, since the LIMIT requested in the prompt may be forgotten on any given turn.

Use the internal connection string and pool connections in-process

With the validating subclass in hand, give it an engine to run on. Render Postgres databases provide internal and external connection strings. The internal URL travels your private network with lower latency, but it only works when your connecting service and your database belong to the same Render workspace and the same region. The external URL works from anywhere and is slower for it, since every query crosses the public internet. Use the internal one wherever possible — see Connecting to Render Postgres.

The two differ only in host. Both use the default Postgres port, 5432, which you can usually leave unspecified:

Store whichever you use as an environment variable and build the engine from it:

That engine is the one every later example uses, both for the formatters below and for the health check at the end.

This is an in-process pool held by your service, which avoids paying the TLS handshake and authentication cost on every query. It differs from Render's connection pooling, which runs PgBouncer in front of the database to multiplex many clients onto fewer connections. You want the in-process pool by default, and most databases never need PgBouncer on top of it. Reach for it when your client count outgrows the connection limit below, or when bursty workloads throw surges of short-lived connections at the database. For a worked example of that second case, see 200 concurrent task runs meet your Postgres connection limit.

Two caveats if you do. The pooler listens on port 6432 rather than the 5432 above, so pointing DATABASE_URL at it means changing both the port and the host. And Render's pooling runs in transaction mode, so session-level features stop working through the pooled connection. Temporary tables, session variables, LISTEN/NOTIFY, and advisory locks all need a direct connection instead. A read-only analytics agent rarely touches any of these, but check before you switch.

Size the pool against your connection limit

Render sets the connection limit by RAM: under 8 GB gets 100 connections, 8–16 GB gets 200, 16–32 GB gets 400, and 32 GB and up gets 500. Count every service sharing the database, and reach for a larger instance type or PgBouncer if you're close to the ceiling (connection limits). For the wider version of this problem, where an agent is one of several services on the same database, see Connecting multiple services to a shared database.

Size the web service instance for the pool as well. Every connection in your SQLAlchemy pool consumes memory in the service process, on top of whatever your result formatting buffers. The Standard web service instance type gives you 2 GB RAM and 1 CPU (Render instance types). Watch the memory metric under real query load rather than guessing, because the pandas summary formatter later in this guide is the line item most likely to surprise you.

Store the agent's credentials as environment variables

Your database URL and model API key don't belong in the repository. Configure them, along with the limits you tuned above, as environment variables:

A fromDatabase reference in your render.yaml Blueprint can populate DATABASE_URL automatically, and connectionString resolves to the internal URL:

Note that the connectionString carries your database's default privileged user role. Connecting the service to the database in the Render Dashboard has the same effect. So use fromDatabase for services that legitimately need full access, and set DATABASE_URL by hand with the agent_readonly credentials for the agent.

Descriptive names, narrowed tables, and views produce better queries

The LLM has nothing to go on but the identifiers you expose, so customer_orders, order_total_amount, and created_timestamp produce better queries than co, amt, and ts.

Then narrow what it sees. Sending the schema for every table wastes context and invites irrelevant joins, so list only the tables the agent needs.

For joins your agent keeps rewriting, move the complexity into the database. A view turns a multi-table analytical query into one flat table the agent can select from:

SQLDatabase doesn't expose views by default, so opt in with view_support=True, or the view you just created stays invisible and the agent keeps writing the join by hand. Both settings belong on the one db the rest of this guide uses:

Remember to grant SELECT on the view itself, because grants on the underlying tables don't cover it.

Return results as structured JSON or a summary, not raw tuples

Some human-readable result sets can blow your context budget, or arrive as undifferentiated walls of tuples that the model has to guess its way through. Two formatting strategies cover most cases.

SQLDatabase.run() returns a string, the repr of a list of row tuples, not structured data. That's adequate for small result sets but gives you nothing to reformat. To control formatting, execute through the pooled engine directly, so you keep column names and typed rows:

Going around SQLDatabase.run costs you one thing worth knowing about: max_string_length no longer applies, because that truncation happens inside run. A single wide text or jsonb column can now spend your whole context budget on one row, which is the outcome this section exists to prevent. Truncate long values yourself in the formatter, or select the columns you need rather than *.

Feed the columns and rows from fetch_rows into either formatter below.

The first turns rows into JSON objects with explicit field names:

default=str is doing real work there. Postgres hands back datetime, Decimal, and UUID values that json.dumps refuses to serialize the moment you point this at real tables rather than a toy schema.

The second returns statistical summaries instead of individual rows, for result sets too large to hand over whole:

Neither formatter does anything until an agent tool calls it, and the toolkit's sql_db_query won't: it calls db.run_no_throw(query) and returns whatever string comes back. So swap that one tool out, keep the other three, and put validation, the row limit, and formatting in the replacement:

This path skips SQLDatabase.run entirely, which is why the tool repeats the validation rather than inheriting it from ValidatedSQLDatabase. The remaining three tools don't route through run either — sql_db_schema reflects and samples through lower-level calls. Keep the subclass anyway, as the chokepoint for any query your own application code runs, but don't count it as the agent's guardrail once you've swapped this tool in. Substitute format_as_summary for format_results_as_json where a summary suits the question better.

Retry transport failures, but hand SQL errors back to the agent

Agent database calls fail in two distinct ways, and they want opposite responses. Transport failures are worth retrying. Bad SQL is not:

The predicate is the part that matters. Without it, tenacity retries every exception it sees, so a bad API key or a rejected query burns all three attempts on a failure that was never going to resolve itself. A query the LLM got wrong should go back to the model, not through a backoff loop: returning the error text as a tool result lets it correct its own SQL, which is what sql_db_query_checker is for.

Cap query duration at the database rather than waiting on the retry layer to notice. Add connect_args to the create_engine call from above, keeping the pool settings alongside it. Postgres wants statement_timeout in milliseconds, so convert the seconds you set in AGENT_QUERY_TIMEOUT:

Health check the database, not just the process

Give Render a health check path that verifies the dependency your agent can't work without. A service with an unreachable database still returns 200 on a trivial check while failing every real request, so confirm connectivity with a cheap query:

Keep the query trivial and let the pool's pool_pre_ping do the connection validation. Render treats any 2xx or 3xx response within five seconds as a pass, stops routing traffic to an instance that fails checks for 15 seconds, and restarts it after 60 seconds (Health Checks). A check that runs an expensive query can trip those thresholds on its own.

Point Render at the endpoint with healthCheckPath in the Blueprint from earlier, which keeps the setting in version control alongside the rest of your service definition:

That single field is the whole configuration. Without a Blueprint, set it under Health Checks on your service's Settings page in the Render Dashboard (setting up health checks).

Build outward from the read-only grant

The through-line here is that the controls you can trust are the ones the model can't reach. A read-only role, a parser that rejects anything but a single SELECT, and a row limit applied in code all hold no matter what ends up in the agent's context. The system prompt, the query checker tool, and the model's own good judgment are worth having, but they produce helpful errors rather than deciding what's possible.

So build outward from the grant. Give the agent its own Postgres role with SELECT on a handful of named tables, point include_tables at that same list, and widen either one only after you've watched real queries for a while. Log every statement from day one, because the query you most want to see is the one you didn't anticipate.

Frequently asked questions