Does the Postgres Checkpointer Serialize Concurrent FastAPI Requests?

Description

I am planning to integrate the LangGraph PostgreSQL checkpointer into a FastAPI application where endpoints execute LangGraph agents.

The application will handle concurrent API requests, each associated with its own LangGraph thread_id. I want to ensure correctness while avoiding unnecessary serialization or performance bottlenecks.

Current Setup

I plan to create a global connection pool and a global checkpointer (saver) during FastAPI startup:

from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

DB_URI = "postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable"

pool = AsyncConnectionPool(
    conninfo=DB_URI,
    min_size=4,
    max_size=20,
    kwargs={"autocommit": True},
)

checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup()

The LangGraph graph is compiled once and reused across requests:

graph = workflow.compile(checkpointer=checkpointer)

Graphs are executed using streaming:

async for chunk in graph.astream(state, config, stream_mode="updates", subgraphs=True):
    _, state_chunk = chunk
    stage, message = list(state_chunk.items())[0]
    print("stage:", stage, ", message:", message)

Internal Lock in PostgresSaver

While inspecting PostgresSaver and AsyncPostgresSaver, I noticed that both implementations acquire an internal lock inside the _cursor context manager.

@asynccontextmanager
async def _cursor(
    self, *, pipeline: bool = False
) -> AsyncIterator[AsyncCursor[DictRow]]:
    """
    ...
	...
	"""
    async with self.lock, _ainternal.get_connection(self.conn) as conn:

This appears to serialize all checkpoint read/write operations per saver instance, even when:

  • A shared PostgreSQL connection pool is used
  • PostgreSQL itself can handle parallel queries

This suggests that using a single global saver may introduce a throughput bottleneck under concurrent FastAPI requests. Do I understand that correctly that concurrent requests are going to be serialized?

Questions

  1. Is it safe and recommended to create a single global Saver and share it across FastAPI requests when using a connection pool?

  2. Does the internal saver lock intentionally serialize all checkpoint I/O operations per saver instance?

  3. For high-concurrency FastAPI applications, what is the recommended pattern?
    A single global saver?
    One saver per request (with graph compilation per request)?
    Process-level scaling (multiple Uvicorn/Gunicorn workers)?

  4. I noticed that RedisSaver does not appear to use a similar lock. Is Redis considered the preferred backend for high-concurrency async web applications?

I would prefer not to initialize a new checkpointer or recompile the graph per FastAPI request unless that is the recommended approach. While graph compilation is relatively fast, it still adds overhead and complexity.

At the same time, I want to avoid unnecessary serialization of concurrent requests.

Documentation

In the documentation Memory - Docs by LangChain ), I see an example usage pattern that appears to initialize a new checkpointer and recompile the graph for each FastAPI request.

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver  

DB_URI = "postgresql://postgres:postgres@localhost:5442/postgres?sslmode=disable"
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
    graph = builder.compile(checkpointer=checkpointer) 

Thank you for taking the time to respond.

Hi @Hanus , answering your questions:

Yes. The persistence docs emphasize that checkpoints are stored per thread_id, and thread_id is required to persist and later retrieve state. As long as each request uses its own thread_id, the data isolation is correct. For async execution (.ainvoke, .astream, etc.), the async saver is the appropriate one. Also, the reference docs are explicit that setup() must be called by the user the first time a Postgres checkpointer is used. ( Persistence - Docs by LangChain )

Based on the _cursor snippet you included (async with self.lock, …), yes: the saver instance serializes operations that pass through _cursor. That’s an implementation detail, but it means a single saver instance becomes a serialization point within a single event loop.

Start with one global saver + one pool + one compiled graph per process, and use multiple worker processes (e.g., multiple Uvicorn/Gunicorn workers) for horizontal scaling. This keeps you aligned with the official API surface while avoiding per-request graph compilation. If you outgrow the per-process lock, then measure and consider:

  • Multiple saver instances per process (each with its own lock).
  • Increasing worker count or splitting traffic across services.

Those are engineering tradeoffs rather than documented requirements. But this is rather my pragmatic advice, not officialy taken from documentation.

The official Python docs include Redis as a supported checkpointer (with examples). But they do not state that Redis is preferred for high concurrency, and the “Use in production” guidance highlights Postgres. So there’s no official basis to say Redis is “preferred”.


Let me know if you have further questions, happy to help

1 Like

Hi @simon.budziak,

Thank you for your answers, I really appreciate them. :slight_smile:

One more question: based on your experience, would you recommend using PostgreSQL or Redis for a checkpointer?

I can imagine a scenario where I would want to migrate to a newer version of langgraph-checkpoint-postgres, but the latest version of the package could be incompatible with my current PostgreSQL database schema due to schema changes. Redis is schema-less, as far as I know, so migrations there seem smoother.

Postgres is recommended. It is maintained by the LangChain team. Schema changes are all made via migrations shipped with the package, but it has been stable schema-wise for a while now.

Redis is maintained by a community member so isn’t guaranteed to have full support.

1 Like

Exactly, as mentioned by @wfh