Module 8: Memory and Persistence
Persistence with PostgresSaver and Redis
Capsule overview
MemorySaver works perfectly on your machine. You open the notebook, talk to the agent, the checkpoints get saved, time-travel works. You close the notebook. You open it the next day. Everything is gone. Every checkpoint, every conversation, every state history — evaporated.
That's fine in development. In production it's unacceptable. Your user talked to the agent yesterday, closed the tab, and today expects to continue. Your server restarted at 3am to apply a security patch, and the 200 active threads were lost. Your app runs on 4 Kubernetes instances, and the user who was on instance 2 now lands on instance 3 — with no context.
PostgresSaver solves all of it. Checkpoints get stored in a PostgreSQL database. They survive crashes, restarts, deploys, and instance migrations. And migrating from MemorySaver is trivial: you change one line of code. One. Everything else — thread_id, get_state(), get_state_history(), time-travel — works exactly the same.
The migration: one line of code
This is the complete transition from development to production:
Before (development with MemorySaver)
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
After (production with PostgresSaver)
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string("postgresql://user:pass@localhost:5432/mydb")
checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
That's it. The rest of your code — the nodes, the edges, the invokes with thread_id, the calls to get_state(), the time-travel — doesn't change a single line. That's the power of LangGraph's checkpointer abstraction: the graph neither knows nor cares where the checkpoints are stored. It only knows that they are.
PostgresSaver: full setup
Installation
pip install langgraph-checkpoint-postgres psycopg[binary]
langgraph-checkpoint-postgres is the package that ships PostgresSaver. psycopg[binary] is the PostgreSQL driver for Python — the binary variant bundles precompiled C libraries so you don't need a compiler.
Connection string
The format is standard PostgreSQL:
postgresql://user:password@host:port/database
| Component | Example | Description |
|---|---|---|
user | postgres | Database user |
password | mysecretpass | The user's password |
host | localhost | Server address |
port | 5432 | PostgreSQL port (default: 5432) |
database | langgraph_app | Database name |
In production, never put the password in the code. Use environment variables:
import os
conn_string = os.environ["DATABASE_URL"]
# Example: DATABASE_URL=postgresql://user:pass@db.example.com:5432/langgraph_prod
Creating the required tables
PostgresSaver needs tables to store checkpoints. The setup() method creates them automatically:
from langgraph.checkpoint.postgres import PostgresSaver
conn_string = "postgresql://postgres:postgres@localhost:5432/langgraph_dev"
checkpointer = PostgresSaver.from_conn_string(conn_string)
checkpointer.setup()
setup() is idempotent — you can run it as many times as you want. If the tables already exist, it does nothing. It's safe to call at application startup.
The async version
For async applications (FastAPI, for instance), use the AsyncPostgresSaver variant:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
conn_string = "postgresql://postgres:postgres@localhost:5432/langgraph_dev"
async def create_graph():
checkpointer = AsyncPostgresSaver.from_conn_string(conn_string)
await checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
return graph
Complete example: chatbot with PostgresSaver
import os
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph, MessagesState, START, END
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
conn_string = os.environ.get(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5432/langgraph_dev"
)
checkpointer = PostgresSaver.from_conn_string(conn_string)
checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "user_001"}}
result = graph.invoke({"messages": [("user", "What is RAG?")]}, config)
print(result["messages"][-1].content)
# Output: "RAG (Retrieval-Augmented Generation) is a technique that..."
result = graph.invoke({"messages": [("user", "Give me examples")]}, config)
print(result["messages"][-1].content)
# Output: "Here are some RAG examples: 1) A chatbot that queries
# internal documentation..."
The code is nearly identical to the MemorySaver version. The only differences:
from langgraph.checkpoint.postgres import PostgresSaverinstead offrom langgraph.checkpoint.memory import MemorySaverPostgresSaver.from_conn_string(conn_string)instead ofMemorySaver()checkpointer.setup()to create the tables
Everything else — thread_id, invoke, get_state(), get_state_history() — is identical. If tomorrow you need to move from Postgres to another backend, you change those 2-3 lines.
Durability: the real difference
The difference between MemorySaver and PostgresSaver isn't the API — it's what happens when something goes wrong.
| Scenario | MemorySaver | PostgresSaver |
|---|---|---|
| 3am deploy | 200 active threads: GONE. Users come back and the agent remembers nothing | 200 threads in PostgreSQL: INTACT. Users pick up where they left off |
| Crash mid-research | 3 of 5 sources processed, crash. Start over: 5 min + double API cost | Resume from source 4: 1 min, no duplicated cost |
| Multi-instance (k8s) | Load balancer routes the request to another instance → no context | Every instance shares the same DB → perfect continuity |
When to use which checkpointer
| Checkpointer | Use case | Durability | Performance | Scalability |
|---|---|---|---|---|
| MemorySaver | Development, testing, notebooks | ❌ Process restart = lost | Fastest (RAM) | Single process only |
| PostgresSaver | Production, multi-instance | ✅ Survives restarts | Good (network + disk) | Multiple instances |
| RedisSaver | High frequency, session caching | ⚠️ Configurable (TTL) | Very fast (RAM + network) | Multiple instances |
Decision tree
Are you in development/testing?
└── Yes → MemorySaver (no setup, no dependencies)
└── No → Do you need full durability?
└── Yes → PostgresSaver (the production standard)
└── No → Do you need maximum speed?
└── Yes → RedisSaver (ephemeral sessions, high frequency)
└── No → PostgresSaver (the production default)
The recommendation for 90% of cases: MemorySaver for development, PostgresSaver for production. RedisSaver is for specific scenarios where read/write latency matters more than absolute durability.
RedisSaver: when you need speed
RedisSaver stores checkpoints in Redis instead of PostgreSQL. Redis runs in memory (like MemorySaver) but it's a separate service that persists data to disk and survives restarts of the Python process.
Installation
pip install langgraph-checkpoint-redis
Basic usage
from langgraph.checkpoint.redis import RedisSaver
checkpointer = RedisSaver.from_conn_string("redis://localhost:6379")
graph = graph_builder.compile(checkpointer=checkpointer)
PostgresSaver vs RedisSaver
| Dimension | PostgresSaver | RedisSaver |
|---|---|---|
| Read latency | ~1-5ms (disk/SSD) | ~0.1-1ms (memory) |
| Write latency | ~2-10ms | ~0.1-1ms |
| Durability | Total (WAL + fsync) | Configurable (RDB/AOF) |
| Capacity | Terabytes (disk) | Bounded by RAM |
| Automatic TTL | Manual (needs cron/trigger) | Native (EXPIRE) |
| Cost | Lower (disk is cheap) | Higher (RAM is expensive) |
| Complex queries | Full SQL | Key-value only |
RedisSaver shines when:
- You have thousands of requests per second and every millisecond counts
- Checkpoints are ephemeral (chat sessions that expire in 24h)
- You already run Redis in your infrastructure
PostgresSaver wins when:
- You need guaranteed durability
- You want to query the checkpoints (analytics, debugging)
- Checkpoints must persist indefinitely
- The data volume grows past what fits in RAM
Connection pooling: handling connections efficiently
In production, every request that needs the checkpointer opens and closes a database connection. With 100 concurrent requests, that's 100 open connections at once. PostgreSQL has a limit (default: 100), and opening/closing them constantly is expensive.
Connection pooling solves this: it keeps a pool of reusable connections.
With the psycopg pool
import os
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
conn_string = os.environ.get(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5432/langgraph_dev"
)
pool = ConnectionPool(
conninfo=conn_string,
min_size=5,
max_size=20,
)
checkpointer = PostgresSaver(conn=pool)
checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
| Parameter | Suggested value | Description |
|---|---|---|
min_size | 5 | Minimum connections kept permanently open |
max_size | 20 | Maximum connections (caps the spikes) |
Rule of thumb: max_size ≤ PostgreSQL's max_connections / number of app instances. If PostgreSQL has max_connections=100 and you run 4 instances, max_size=20 per instance (4 × 20 = 80, leaving headroom).
For async (FastAPI), use AsyncConnectionPool from psycopg_pool with AsyncPostgresSaver — same parameters, same logic, but with await pool.open().
Checkpoint retention and cleanup
Checkpoints pile up. Without a retention policy, the database grows forever. Three strategies: by time (delete anything older than N days), by count (keep the last N per thread), or by activity (inactive threads).
In PostgreSQL, direct cleanup with SQL:
DELETE FROM checkpoints WHERE created_at < NOW() - INTERVAL '30 days';
In Redis, native TTL — the checkpoints expire on their own:
checkpointer = RedisSaver.from_conn_string("redis://localhost:6379", ttl={"default": 86400})
Storage rules: don't put large payloads in the state (use references, not content), apply message trimming (capsule 02), and monitor size with periodic SQL queries.
Production pattern: picking the checkpointer by environment
import os
def get_checkpointer():
env = os.environ.get("ENVIRONMENT", "development")
if env == "development":
from langgraph.checkpoint.memory import MemorySaver
return MemorySaver()
elif env in ("production", "staging"):
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string(os.environ["DATABASE_URL"])
checkpointer.setup()
return checkpointer
raise ValueError(f"Unknown environment: {env}")
graph = graph_builder.compile(checkpointer=get_checkpointer())
The graph doesn't change. The nodes don't change. The tests don't change. Only the checkpointer varies by environment.
Complete example: FastAPI with async PostgresSaver
A production pattern with FastAPI: the lifespan creates the pool and the checkpointer at startup, and closes them at shutdown. The pool is shared across all requests. Each request passes its thread_id and uses ainvoke (async). The server can restart and the conversations persist.
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.graph import StateGraph, MessagesState, START, END
from psycopg_pool import AsyncConnectionPool
graph = None
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
@asynccontextmanager
async def lifespan(app: FastAPI):
global graph
conn_string = os.environ["DATABASE_URL"]
pool = AsyncConnectionPool(conninfo=conn_string, min_size=5, max_size=20)
await pool.open()
checkpointer = AsyncPostgresSaver(conn=pool)
await checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
yield
await pool.close()
app = FastAPI(lifespan=lifespan)
class ChatRequest(BaseModel):
message: str
thread_id: str
@app.post("/chat")
async def chat(request: ChatRequest):
config = {"configurable": {"thread_id": request.thread_id}}
result = await graph.ainvoke(
{"messages": [("user", request.message)]}, config
)
return {"response": result["messages"][-1].content, "thread_id": request.thread_id}
# POST /chat {"message": "What is RAG?", "thread_id": "user_001"}
# → {"response": "RAG is...", "thread_id": "user_001"}
Troubleshooting
Problem 1: "relation 'checkpoints' does not exist"
Symptom: An error on the first invoke after configuring PostgresSaver.
Cause: You didn't call checkpointer.setup() to create the tables.
Fix: Add checkpointer.setup() (or await checkpointer.setup() in async) after creating the checkpointer. It's idempotent — safe to call on every startup.
Problem 2: "connection refused" when connecting to PostgreSQL
Symptom: psycopg.OperationalError: connection to server at "localhost"... refused.
Cause: PostgreSQL isn't running, or the port/host is wrong.
Fix: Check that PostgreSQL is up (pg_isready -h localhost -p 5432). If you use Docker: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16.
Problem 3: "too many clients already" in production
Symptom: psycopg.OperationalError: too many clients already under load.
Cause: Every request opens a new connection and no pool is configured, or the pool's max_size exceeds PostgreSQL's max_connections.
Fix: Use ConnectionPool with an appropriate max_size (see the connection pooling section). Rule: max_size × num_instances < max_connections.
Problem 4: Degraded performance with many checkpoints
Symptom: get_state_history() is slow after thousands of invocations on a thread.
Cause: The checkpoints table grew without extra indexing or a retention policy.
Fix: Implement a retention policy (delete old checkpoints) and confirm the table's indexes are in place. See the checkpoint management section.
Problem 5: MemorySaver in production "works" but loses data
Symptom: Everything is fine until the server restarts and the conversations vanish. Cause: MemorySaver is running in production. Fix: Migrate to PostgresSaver. It's literally a one-line change (see "The migration").
Exercises
Exercise 1: MemorySaver → PostgresSaver migration (Easy)
Here's some code using MemorySaver. Change it to use PostgresSaver with the connection string "postgresql://postgres:postgres@localhost:5432/langgraph_dev". Don't change anything except the checkpointer lines.
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
checkpointer = MemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
See solution
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph, MessagesState, START, END
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
conn_string = "postgresql://postgres:postgres@localhost:5432/langgraph_dev"
checkpointer = PostgresSaver.from_conn_string(conn_string)
checkpointer.setup()
graph = graph_builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "migration_test"}}
result = graph.invoke({"messages": [("user", "Did the migration work?")]}, config)
print(result["messages"][-1].content)
# Expected output: Yes, the migration works. The agent answers normally.
state = graph.get_state(config)
print(f"Checkpoint stored in Postgres: {len(state.values['messages'])} messages")
print("✅ Migration successful — checkpoints now persist in PostgreSQL")
# Expected output:
# Checkpoint stored in Postgres: 2 messages
# ✅ Migration successful — checkpoints now persist in PostgreSQL
Explanation: Three changes: (1) swap the import from MemorySaver to PostgresSaver, (2) use PostgresSaver.from_conn_string() instead of MemorySaver(), (3) call checkpointer.setup(). Everything else — nodes, edges, invoke, config — is identical.
Exercise 2: Picking a checkpointer by environment (Easy)
Write a function get_checkpointer(env: str) that returns MemorySaver for "development", PostgresSaver for "production" (with the connection string from an environment variable), and raises ValueError for anything else. Test it with "development" and check that it returns a MemorySaver.
See solution
import os
from langgraph.checkpoint.memory import MemorySaver
def get_checkpointer(env: str):
if env == "development":
return MemorySaver()
elif env == "production":
from langgraph.checkpoint.postgres import PostgresSaver
conn_string = os.environ.get("DATABASE_URL")
if not conn_string:
raise ValueError("DATABASE_URL environment variable required for production")
checkpointer = PostgresSaver.from_conn_string(conn_string)
checkpointer.setup()
return checkpointer
raise ValueError(f"Unknown environment: {env}")
dev_checkpointer = get_checkpointer("development")
print(f"Dev checkpointer: {type(dev_checkpointer).__name__}")
assert type(dev_checkpointer).__name__ == "MemorySaver"
try:
get_checkpointer("unknown")
except ValueError as e:
print(f"Expected error: {e}")
print("✅ The checkpointer selector works correctly")
# Expected output:
# Dev checkpointer: MemorySaver
# Expected error: Unknown environment: unknown
# ✅ The checkpointer selector works correctly
Explanation: The PostgresSaver import is lazy (inside the elif) so development doesn't break if langgraph-checkpoint-postgres isn't installed. Validating DATABASE_URL prevents cryptic errors in production.
Exercise 3: Verify persistence across a simulated restart (Medium)
Build a graph with MemorySaver. Make 3 invocations on one thread. Then simulate a "restart" by creating a fresh MemorySaver() and compiling a new graph. Verify that the new graph does NOT have the previous checkpoints. Then repeat the experiment reusing the same checkpointer object — verify that it DOES keep the checkpoints. This is exactly why MemorySaver isn't fit for production.
See solution
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
def build_graph(checkpointer):
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
return builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "persist_test"}}
print("=== Scenario 1: New MemorySaver (simulates a restart) ===")
checkpointer_v1 = MemorySaver()
graph_v1 = build_graph(checkpointer_v1)
graph_v1.invoke({"messages": [("user", "Hi, this is turn 1")]}, config)
graph_v1.invoke({"messages": [("user", "This is turn 2")]}, config)
graph_v1.invoke({"messages": [("user", "And this is turn 3")]}, config)
state_v1 = graph_v1.get_state(config)
print(f"Before the restart: {len(state_v1.values['messages'])} messages")
checkpointer_v2 = MemorySaver()
graph_v2 = build_graph(checkpointer_v2)
state_v2 = graph_v2.get_state(config)
has_state = state_v2.values is not None and len(state_v2.values.get("messages", [])) > 0
print(f"After the restart: {'has data' if has_state else 'empty'}")
assert not has_state, "It shouldn't have data after a simulated restart"
print("❌ Checkpoints lost — MemorySaver doesn't persist across restarts\n")
print("=== Scenario 2: Same MemorySaver (no real restart) ===")
shared_checkpointer = MemorySaver()
graph_a = build_graph(shared_checkpointer)
graph_a.invoke({"messages": [("user", "Message in graph A")]}, config)
graph_b = build_graph(shared_checkpointer)
state_b = graph_b.get_state(config)
has_state_b = state_b.values is not None and len(state_b.values.get("messages", [])) > 0
print(f"Graph B with the same checkpointer: {'has data' if has_state_b else 'empty'}")
assert has_state_b
print("✅ Checkpoints preserved — same object in memory")
print("Takeaway: MemorySaver loses data when you create a new instance. PostgresSaver doesn't.")
# Expected output:
# Before the restart: 6 messages → After: empty
# ❌ MemorySaver doesn't persist → ✅ The same object does persist
Explanation: Creating a new MemorySaver() is the equivalent of a process restart — the RAM is wiped. Reusing the same object keeps the data, but in production you can't guarantee that. PostgresSaver decouples the data from the process.
Exercise 4: Connection pool with validation (Medium)
Write create_pooled_checkpointer(conn_string, min_size, max_size) with validation: min_size >= 1, max_size >= min_size, max_size <= 50. Test that the validations reject dangerous configurations.
See solution
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
def create_pooled_checkpointer(conn_string: str, min_size: int = 5, max_size: int = 20):
if min_size < 1:
raise ValueError(f"min_size must be >= 1, got: {min_size}")
if max_size < min_size:
raise ValueError(f"max_size ({max_size}) must be >= min_size ({min_size})")
if max_size > 50:
raise ValueError(f"max_size must be <= 50, got: {max_size}")
pool = ConnectionPool(conninfo=conn_string, min_size=min_size, max_size=max_size)
checkpointer = PostgresSaver(conn=pool)
checkpointer.setup()
return checkpointer
invalid_cases = [(0, 10), (10, 5), (5, 100)]
for min_s, max_s in invalid_cases:
try:
create_pooled_checkpointer("postgresql://localhost/test", min_s, max_s)
print(f" ❌ min={min_s}, max={max_s}: should have failed")
except ValueError as e:
print(f" ✅ min={min_s}, max={max_s}: rejected ({e})")
print("✅ The pool validations work correctly")
# Expected output:
# ✅ min=0, max=10: rejected (min_size must be >= 1...)
# ✅ min=10, max=5: rejected (max_size (5) must be >= min_size...)
# ✅ min=5, max=100: rejected (max_size must be <= 50...)
# ✅ The pool validations work correctly
Explanation: Capping max_size at 50 keeps you from exhausting PostgreSQL's connections. In production, tune it against your instance's max_connections.
Exercise 5: Checkpoint cleanup function (Medium-Advanced)
Write a function cleanup_old_threads(graph, known_thread_ids, max_age_turns, active_thread_ids) that returns the thread_ids that should be cleaned up (inactive ones with more than max_age_turns turns). Simulate it with MemorySaver.
See solution
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
return {"messages": [model.invoke(state["messages"])]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
checkpointer = MemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
def populate_thread(graph, thread_id: str, num_turns: int):
config = {"configurable": {"thread_id": thread_id}}
for i in range(num_turns):
graph.invoke({"messages": [("user", f"Turn {i+1}")]}, config)
def cleanup_old_threads(graph, known_ids, max_age_turns, active_ids):
to_clean = []
for tid in known_ids:
if tid in active_ids:
continue
state = graph.get_state({"configurable": {"thread_id": tid}})
if state.values and len(state.values.get("messages", [])) // 2 > max_age_turns:
to_clean.append(tid)
return to_clean
populate_thread(graph, "alice", 3)
populate_thread(graph, "bob", 15)
populate_thread(graph, "carol", 8)
populate_thread(graph, "dave", 2)
populate_thread(graph, "eve", 20)
to_clean = cleanup_old_threads(
graph, ["alice", "bob", "carol", "dave", "eve"],
max_age_turns=10, active_ids={"alice", "carol"}
)
print(f"Threads to clean up: {to_clean}")
assert "bob" in to_clean and "eve" in to_clean
assert "alice" not in to_clean and "dave" not in to_clean
print("✅ Cleanup logic verified")
# Expected output:
# Threads to clean up: ['bob', 'eve']
# ✅ Cleanup logic verified
Explanation: In production with PostgresSaver, this would be SQL (DELETE FROM checkpoints WHERE thread_id = ...). Active threads are always protected; inactive ones with many turns get flagged for cleanup.
Exercise 6: FastAPI endpoint with persistence (Advanced)
Write a POST /chat endpoint in FastAPI that takes message and thread_id, uses a graph with MemorySaver, and returns the response. The checkpointer must be initialized in the lifespan. Include comments showing what to change to migrate to AsyncPostgresSaver.
See solution
from contextlib import asynccontextmanager
from fastapi import FastAPI
from pydantic import BaseModel
from langchain.chat_models import init_chat_model
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, MessagesState, START, END
graph = None
def chatbot(state: MessagesState) -> dict:
model = init_chat_model("openai:gpt-4.1-mini")
response = model.invoke(state["messages"])
return {"messages": [response]}
graph_builder = StateGraph(MessagesState)
graph_builder.add_node("chatbot", chatbot)
graph_builder.add_edge(START, "chatbot")
graph_builder.add_edge("chatbot", END)
@asynccontextmanager
async def lifespan(app: FastAPI):
global graph
# To migrate to PostgresSaver, change these lines:
# from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
# checkpointer = AsyncPostgresSaver.from_conn_string(os.environ["DATABASE_URL"])
# await checkpointer.setup()
checkpointer = MemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
yield
app = FastAPI(lifespan=lifespan)
class ChatRequest(BaseModel):
message: str
thread_id: str
@app.post("/chat")
async def chat(request: ChatRequest):
config = {"configurable": {"thread_id": request.thread_id}}
result = await graph.ainvoke(
{"messages": [("user", request.message)]}, config
)
return {
"response": result["messages"][-1].content,
"thread_id": request.thread_id,
"total_messages": len(result["messages"])
}
# uvicorn module_name:app --reload
# curl -X POST http://localhost:8000/chat -H "Content-Type: application/json" \
# -d '{"message": "What is RAG?", "thread_id": "test_001"}'
# → {"response": "RAG is...", "thread_id": "test_001", "total_messages": 2}
Explanation: The checkpointer is created in lifespan and shared across every request. To migrate to Postgres, you change 2-3 lines inside lifespan. The endpoints don't change. ainvoke (instead of invoke) is what makes it async-compatible with FastAPI.
Summary
In this capsule you learned:
- Migrating from MemorySaver to PostgresSaver is one line of code. Swap
MemorySaver()forPostgresSaver.from_conn_string(conn_string)— everything else (thread_id, get_state, get_state_history, time-travel) behaves identically. That's the power of the checkpointer abstraction - PostgresSaver = real durability. Checkpoints survive crashes, restarts, deploys, and instance migrations. In a multi-instance setup (Kubernetes), every pod shares the same database
- RedisSaver is for high frequency. Sub-millisecond latency, native TTL to expire checkpoints automatically. Ideal for ephemeral sessions. But durability isn't absolute like it is with Postgres
- Connection pooling is mandatory in production. Without a pool, every request opens a fresh connection. With 100 concurrent requests, you exhaust PostgreSQL's connections.
ConnectionPool(min_size=5, max_size=20)solves it - Checkpoints accumulate and need retention. A cleanup policy by time, by count, or by activity. In Redis, native TTL. In PostgreSQL, periodic SQL queries
- The production pattern: MemorySaver for development (no dependencies), PostgresSaver + connection pooling for production (durability + scale), selected via an environment variable
Further reading
- LangGraph — PostgresSaver — API reference for PostgresSaver with every method and configuration option
- LangGraph — Persistence How-to — Official guide to implementing persistence with different backends
- psycopg3 — Connection Pools — Docs for psycopg3's connection pool, the driver PostgresSaver uses
- Redis Persistence — redis.io — How Redis persists data to disk: RDB snapshots vs the AOF log. Important for understanding RedisSaver's durability
- PostgreSQL Connection Management — Official PostgreSQL docs on
max_connectionsand connection management
Module 8 — LangChain & LangGraph: From Chains to Agents