LangGraph agents with Postgres: tools, retrieval, and safe SQL
Build a tenant-scoped LangChain/LangGraph agent that queries Postgres with parameterized tools and retrieves knowledge via full-text search or pgvector — in Python and TypeScript.
Most agent tutorials stop at mock dictionaries. Production copilots need live rows: accounts, tickets, policies — and they need them without turning the model into a SQL client.
This guide builds a tenant-scoped research agent with LangChain tools and a LangGraph loop that reads Postgres safely, then retrieves supporting articles. Examples are paired in Python and TypeScript. For ReAct basics and memory, start with Build an agent with LangChain.
User intent
Message + page context
Orchestrator
LLM plans next step
Tool proposed
Function + arguments
Permission gate
RBAC, allowlist, confirm
Product API
Your systems of record
Observation
Result back to model
Response
Streamed to UI
The loop repeats until the model responds or hits a step limit. Every tool call passes through your permission gate — not straight to the provider.
What you will build
A support research agent that can:
- Look up an account with parameterized SQL (
get_account) - List open tickets for that account (
list_open_tickets) - Retrieve knowledge articles via Postgres full-text search (
search_knowledge) - Answer with citations to account IDs and article titles
Optional: swap or add a pgvector semantic search tool when keyword search is not enough.
Schema sketch
Keep the example schema small enough to reason about:
CREATE TABLE accounts (
id text PRIMARY KEY,
tenant_id text NOT NULL,
name text NOT NULL,
plan text NOT NULL,
mrr numeric
);
CREATE TABLE tickets (
id text PRIMARY KEY,
tenant_id text NOT NULL,
account_id text NOT NULL REFERENCES accounts(id),
subject text NOT NULL,
status text NOT NULL,
priority text,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE knowledge_articles (
id text PRIMARY KEY,
tenant_id text NOT NULL,
title text NOT NULL,
body text NOT NULL
);Prerequisites
- Python 3.11+ and Node 20+ (pick the stack you ship; examples cover both)
- Postgres locally or on Neon
- An OpenAI API key (or swap the chat model provider)
Install dependencies:
pip install langgraph langchain-openai langchain-core "psycopg[binary,pool]" python-dotenvSet DATABASE_URL and OPENAI_API_KEY in the environment.
Why not give the model SQL
A generic execute_sql(query: str) tool is convenient in a demo and dangerous in a multi-tenant product. Prompt injection, confused models, and sloppy tool schemas all converge on the same failure: the model chooses the query string.
| Risk | What goes wrong | Mitigation |
|---|---|---|
| SQL injection | Model concatenates untrusted text into SQL | Parameterized queries only (%s / $1) |
| Tenant bleed | Query omits tenant_id | Close over tenantId from auth; never take it as a tool arg |
| Destructive writes | DELETE / UPDATE via free-form SQL | Read-only DB role; no write tools in this agent |
| Runaway cost | Huge result sets → huge prompts | Hard LIMIT, compact JSON, recursion limits |
Rule of thumb: reads by default. Writes belong in a separate graph path with confirmation gates — see Prompt injection and LLM security for SaaS.
Allowlisted tools beat a SQL sandbox for product agents. You want get_account, not run_whatever.
Connection and tenancy
Create a shared pool. Every tool call uses parameters, never string interpolation of user or model text.
import os
import json
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
DATABASE_URL = os.environ["DATABASE_URL"]
pool = ConnectionPool(
conninfo=DATABASE_URL,
min_size=1,
max_size=10,
kwargs={"row_factory": dict_row},
)
def query(sql: str, params: tuple) -> list[dict]:
"""Run a parameterized query. Never interpolate user or model text into SQL."""
with pool.connection() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
return list(cur.fetchall())
def compact_json(rows: list[dict], limit: int = 20) -> str:
return json.dumps(rows[:limit], default=str)The critical design choice: tenant_id is injected when you build tools, after auth middleware resolves the signed-in user. The model never supplies it. If tenant_id is a tool argument, prompt injection becomes cross-tenant data access.
Define Postgres tools
Factory pattern: build_tools(tenant_id) returns tools closed over that tenant.
from langchain_core.tools import tool
def build_tools(tenant_id: str):
"""tenant_id comes from auth middleware — never from the model."""
@tool
def get_account(account_id: str) -> str:
"""Fetch one account by ID for the current tenant."""
rows = query(
"""
SELECT id, name, plan, mrr
FROM accounts
WHERE tenant_id = %s AND id = %s
LIMIT 1
""",
(tenant_id, account_id),
)
return compact_json(rows) if rows else "Account not found."
@tool
def list_open_tickets(account_id: str) -> str:
"""List open support tickets for an account (max 20)."""
rows = query(
"""
SELECT id, subject, status, priority, updated_at
FROM tickets
WHERE tenant_id = %s
AND account_id = %s
AND status = 'open'
ORDER BY updated_at DESC
LIMIT 20
""",
(tenant_id, account_id),
)
return compact_json(rows) if rows else "No open tickets."
@tool
def search_knowledge(query_text: str) -> str:
"""Search knowledge articles with Postgres full-text search."""
rows = query(
"""
SELECT id, title,
ts_rank(search_vector, plainto_tsquery('english', %s)) AS rank
FROM knowledge_articles
WHERE tenant_id = %s
AND search_vector @@ plainto_tsquery('english', %s)
ORDER BY rank DESC
LIMIT 8
""",
(query_text, tenant_id, query_text),
)
return compact_json(rows) if rows else "No matching articles."
return [get_account, list_open_tickets, search_knowledge]Return compact JSON strings, not driver objects. Cap rows (LIMIT 8–20) so a noisy table does not blow the context window. Tool descriptions matter — they are how the model chooses which query to run.
Wire LangGraph
Use the prebuilt ReAct agent. Pass a system prompt that restates the tenant boundary and citation rules.
from langgraph.prebuilt import create_react_agent
from langchain_openai import ChatOpenAI
def build_agent(tenant_id: str):
model = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
tools = build_tools(tenant_id)
system = (
"You are a support research agent. "
f"You serve tenant {tenant_id} only. "
"Use tools for live data. Cite account IDs and article titles. "
"If a tool returns empty, say you do not have that data."
)
return create_react_agent(
model,
tools,
prompt=system,
)
def run_agent(tenant_id: str, message: str) -> str:
agent = build_agent(tenant_id)
result = agent.invoke(
{"messages": [("user", message)]},
config={"recursion_limit": 10},
)
return result["messages"][-1].contentInvoke only on the server (FastAPI route, Next.js Route Handler). The browser sends a message; middleware authenticates, resolves tenantId, builds tools, runs the graph.
Example user message the agent should handle:
What is Acme's plan, and are there open tickets? Also check our refund policy.
Expected tool sequence: get_account → list_open_tickets → search_knowledge → final answer with citations.
Retrieval patterns on Postgres
Not every question needs embeddings. Match the retrieval style to the question — same decision frame as When not to use RAG:
| Need | Tool shape |
|---|---|
| Known entity ID | Structured SQL tool (get_account) |
| Keyword / policy docs | Postgres full-text (search_knowledge) |
| Fuzzy semantic similarity | Optional pgvector tool |
RAG is a tool inside the agent, not a replacement for SQL lookups. Looking up acme-001 with vectors is slower and less precise than WHERE id = $2.
Full-text search
Add a generated tsvector column and GIN index (migration):
ALTER TABLE knowledge_articles
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
) STORED;
CREATE INDEX IF NOT EXISTS knowledge_articles_search_idx
ON knowledge_articles USING GIN (search_vector);Query helper (same pattern as the tool body):
def search_knowledge_fts(tenant_id: str, query_text: str) -> str:
rows = query(
"""
SELECT id, title, left(body, 400) AS excerpt
FROM knowledge_articles
WHERE tenant_id = %s
AND search_vector @@ plainto_tsquery('english', %s)
ORDER BY ts_rank(search_vector, plainto_tsquery('english', %s)) DESC
LIMIT 8
""",
(tenant_id, query_text, query_text),
)
return compact_json(rows)For integrating retrieval without a separate vector platform, see RAG without the platform rewrite.
Optional: pgvector
When keyword search misses paraphrases, add an embedding vector(...) column and a semantic tool. Keep tenant filters and LIMIT the same as FTS.
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
@tool
def search_knowledge_semantic(query_text: str) -> str:
"""Semantic search over knowledge articles (pgvector)."""
vector = embeddings.embed_query(query_text)
rows = query(
"""
SELECT id, title,
1 - (embedding <=> %s::vector) AS score
FROM knowledge_articles
WHERE tenant_id = %s
ORDER BY embedding <=> %s::vector
LIMIT 8
""",
(vector, tenant_id, vector),
)
return compact_json(rows) if rows else "No matching articles."Register both FTS and semantic tools if you need them — the model can choose. Start with FTS; add pgvector when evals show a real gap.
Production boundaries
| Concern | Practice |
|---|---|
| Where it runs | Server-side only — never ship DB credentials or tool definitions to the browser |
| Auth | Resolve tenantId (and role) before build_tools / build_agent |
| DB role | Connect with a read-only Postgres role for this agent |
| Loops | recursion_limit / recursionLimit on invoke; alert on high step counts |
| Timeouts | Statement timeout on the pool; overall request timeout in middleware |
| Observability | Trace each tool name, latency, and tenant — Langfuse or PostHog |
| Evals | Golden questions that must call the right tool and refuse cross-tenant asks — eval pipelines |
Shared rate limits, provider routing, and cost controls belong in LLM middleware, regardless of LangGraph vs a thinner stack.
If external systems enter the tool surface later, MCP in production SaaS covers allowlists and least privilege — the same bar as native tools.
Common mistakes
Model-authored SQL. Free-form execute_sql turns prompt injection into database access. Use allowlisted tools.
Model-supplied tenantId. Always close over auth context. Treat tool arguments as untrusted.
Unbounded SELECT *. Hard LIMIT, selected columns, truncated excerpts.
Write tools in the same unconstrained loop. Separate graphs or human-in-the-loop nodes for mutations.
Client-side LangGraph with a database URL. Credentials and tool I/O stay on the server.
Skipping evals. Assert tool choice and refusals on a small golden set before widening traffic.
Next steps
- Point tools at your staging schema with a read-only role
- Add 20–30 golden questions (correct tool, citation present, refuse missing data)
- Stream graph events to the UI once the tool path is stable
- Ship behind a feature flag; watch tool error rate and tokens per session
Building the loop is straightforward. Making Postgres access permissioned, parameterized, and observable is the integration work. Describe your schema and workflow — we will map tools, tenancy, and retrieval for your stack.
Related resources
More on agents- Build an agent with LangChain — a practical tutorial
Step-by-step guide to building a tool-calling agent with LangChain and LangGraph, from first prototype to patterns that survive production.
- RAG without the platform rewrite
How to add retrieval over your existing data without standing up a separate vector platform or pausing the product roadmap.
- When not to use RAG
RAG is the default answer for every AI feature — but often the wrong one. A decision guide for engineering leaders scoping retrieval, tools, and middleware.
