LangGraph Tutorial (TypeScript): connecting to PostgreSQL for beginners
This tutorial shows you how to connect a LangGraph TypeScript app to PostgreSQL, store conversation state, and resume a graph run later. You need this when your agent can’t be stateless: think chat sessions, workflow checkpoints, retries, or any case where you want durable memory instead of in-process objects.
What You'll Need
- •Node.js 18+
- •A PostgreSQL instance you can connect to locally or remotely
- •A
DATABASE_URLconnection string - •A TypeScript project with
langgraphinstalled - •
pgfor PostgreSQL access - •
dotenvif you want to load environment variables from a.envfile
Install the packages:
npm install langgraph pg dotenv
npm install -D typescript tsx @types/node @types/pg
Set your environment variable:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/langgraph_demo
Step-by-Step
- •Create a PostgreSQL table for checkpoints.
LangGraph needs somewhere to persist graph state between runs. For beginners, the simplest pattern is a dedicated table that stores checkpoint data as JSONB.
CREATE TABLE IF NOT EXISTS langgraph_checkpoints (
thread_id TEXT PRIMARY KEY,
checkpoint JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
- •Build a tiny PostgreSQL-backed checkpointer.
This implementation uses pg directly so you can see exactly what is happening. It stores one checkpoint per thread_id, which is enough to get started and works well for simple conversational agents.
import "dotenv/config";
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export async function saveCheckpoint(threadId: string, checkpoint: unknown) {
await pool.query(
`
INSERT INTO langgraph_checkpoints (thread_id, checkpoint, updated_at)
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (thread_id)
DO UPDATE SET checkpoint = EXCLUDED.checkpoint, updated_at = NOW()
`,
[threadId, JSON.stringify(checkpoint)]
);
}
export async function loadCheckpoint(threadId: string) {
const result = await pool.query(
`SELECT checkpoint FROM langgraph_checkpoints WHERE thread_id = $1`,
[threadId]
);
return result.rows[0]?.checkpoint ?? null;
}
- •Create a LangGraph workflow that uses persisted state.
This graph appends user messages into state and returns the latest answer. The key part is the MemorySaver checkpointer from LangGraph; it keeps the graph API shape correct while you handle persistence separately in PostgreSQL for now.
import "dotenv/config";
import { Annotation, StateGraph } from "@langchain/langgraph";
import { MemorySaver } from "@langchain/langgraph/checkpoint";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
const State = Annotation.Root({
messages: Annotation<any[]>({
reducer: (left, right) => left.concat(right),
default: () => [],
}),
});
const graph = new StateGraph(State)
.addNode("chat", async (state) => {
const lastUserMessage = [...state.messages].reverse().find((m) => m instanceof HumanMessage);
return {
messages: [
new AIMessage(`You said: ${lastUserMessage?.content ?? "nothing yet"}`),
],
};
})
.addEdge("__start__", "chat")
.addEdge("chat", "__end__");
const app = graph.compile({
checkpointer: new MemorySaver(),
});
- •Run the graph with a stable thread ID.
The thread ID is what lets LangGraph treat each conversation as a durable session. In production, this value usually comes from your user ID, chat ID, or workflow ID.
import { HumanMessage } from "@langchain/core/messages";
async function main() {
const config = {
configurable: {
thread_id: "customer-123",
},
};
const firstRun = await app.invoke(
{ messages: [new HumanMessage("Hello")] },
config
);
console.log(firstRun.messages.at(-1)?.content);
const secondRun = await app.invoke(
{ messages: [new HumanMessage("What did I just say?")] },
config
);
console.log(secondRun.messages.at(-1)?.content);
}
main();
- •Persist and reload graph state in PostgreSQL.
If you want actual database persistence beyond process memory, save the returned state after each run and reload it before continuing. This is the beginner-friendly bridge between LangGraph state and PostgreSQL storage.
import { saveCheckpoint, loadCheckpoint } from "./postgres-checkpoint";
import { HumanMessage } from "@langchain/core/messages";
async function runWithPostgres(threadId: string, userText: string) {
const previous = await loadCheckpoint(threadId);
const input =
previous?.messages?.length > 0
? previous
: { messages: [] };
const nextState = await app.invoke(
{
...input,
messages: [...(input.messages ?? []), new HumanMessage(userText)],
},
{ configurable: { thread_id: threadId } }
);
await saveCheckpoint(threadId, nextState);
return nextState;
}
Testing It
Start your PostgreSQL server and make sure the database exists before running the script. Then execute your TypeScript entry file with tsx, send two requests using the same thread_id, and confirm that the second run can see state from the first one.
A simple test is to inspect the langgraph_checkpoints table after each invocation. You should see one row per thread with updated JSON content and a fresh updated_at timestamp.
If something fails, check these first:
- •
DATABASE_URLis correct - •The table exists in the target database
- •Your TypeScript runtime can import ESM packages cleanly
For production debugging, log both the thread_id and the serialized checkpoint size. That catches most issues fast when agents start losing context or writing malformed state.
Next Steps
- •Replace the toy echo node with a real LLM node using
ChatOpenAI - •Add message trimming so your PostgreSQL payload doesn’t grow forever
- •Move from one-row-per-thread storage to full checkpoint history for auditability
Keep learning
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
By Cyprian Aarons, AI Consultant at Topiax.
Want the complete 8-step roadmap?
Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.
Get the Starter Kit