Fixes langchain-ai/langgraph#8382 `DeltaChannel` rebuilds its value by replaying ancestor writes through the reducer. Every saver ordered a checkpoint's writes by `(task_id, idx)`, but live execution applies a super-step's writes in task-path order (`apply_writes` sorts tasks by `task_path_str(task.path[:3])`). `task_id` is a hash of the path, so when two or more tasks write one `DeltaChannel` in the same super-step, replay applies them in an arbitrary permutation. Reducers must be batching-invariant, not order-invariant, so the permutation changes the value: `get_state` disagrees with what `invoke` returned, and continuing the thread saves the reordered value as the base for every later write. ## Fix Replay orders by `(task_path, task_id, idx)`, the order `SELECT_PENDING_SENDS_SQL` already uses for sends. `apply_writes` and every saver now sort with one `writes_sort_key` from `langgraph.checkpoint.base`, in Python, so live and replay can't drift apart and don't depend on a database collation. `apply_writes` sorts by the full task path, the string `put_writes` stores, instead of `path[:3]`. The paths that reach `apply_writes` no longer carry task ids, so the live order doesn't change. `BaseCheckpointSaver`'s default `get_delta_channel_history` can't sort by path, since `PendingWrite` has no task path, so it replays each checkpoint's writes in `get_tuple` order. `get_tuple` now has to return `pending_writes` in `writes_sort_key` order, and memory, SQLite and Postgres do, so the default walk is right for any saver that follows that. `AsyncPostgresSaver` also gets the sync `get_delta_channel_history` that `AsyncSqliteSaver` already had; sync reads used to fall back to the default walk. Memory and Postgres already stored `task_path`. SQLite accepted it on `put_writes` and dropped it, so `writes` gains the column and `setup()` adds it to existing databases. Writes without a task path (graph input, `update_state` updates, rows written before the column existed) sort first by `task_id`, which is also where live execution applies input. ## Compatibility - Memory and Postgres already had `task_path` on disk, so existing threads replay in the corrected order after upgrade. - On SQLite, rows from before the upgrade read back as `''` and keep their old order. Only new writes get the corrected order, since there is no stored path to recover. - SQLite has no `ADD COLUMN IF NOT EXISTS`, so `setup()` runs the `ALTER` and treats `duplicate column name` as success, which also covers two processes migrating one file. That error comes while SQLite prepares the statement, before it asks for the write lock, so `setup()` on an up-to-date database doesn't wait on other writers. A read-only database from before the column is read with empty paths instead of failing setup. - Downgrade is safe: older code's `INSERT` omits the column and the column has a default. - `get_tuple` and `list` return `pending_writes` in `writes_sort_key` order instead of task id order (SQLite, Postgres) or put order (memory). Nothing in langgraph depends on the old order. - Postgres reads `task_path` and `idx` with each pending write, so a subclass that pairs `SELECT_SQL` with its own `_load_writes`, or the other way round, has to match the new row shape. - A third-party saver has to replay in `writes_sort_key` order: in its own `get_delta_channel_history` if it has one, otherwise by returning `pending_writes` from `get_tuple` in that order. Otherwise it fails the new conformance tests; the `get_tuple` one is a base test, so it runs for every saver. - langgraph, checkpoint-sqlite and checkpoint-postgres import `writes_sort_key` from langgraph-checkpoint, so this bumps langgraph-checkpoint to 4.3.0 and requires it there. 4.3.0 has to be released first. ## Limits - Several updates in one `bulk_update_state` super-step share a task id, so the saver keeps only the first one's writes and the `DeltaChannel` loses the rest (plain channels are fine). That's on main too; #9128 fixes it and stores a task path per update, so with this PR those updates also replay in the order given. - Exit durability stores its writes differently; resume ordering there is fixed in #9114, stacked on this PR. - langgraphjs applies `DeltaChannel` writes in task id order, live and on replay (langchain-ai/langgraphjs#2544), so its reads match too. Moving JS to task path order is a follow-up. ## Tests Each fails on main and passes here: - `test_delta_channel_parallel_order.py`, across memory, SQLite and Postgres: parallel writers and a `Send` fan-out, comparing `get_state` with the live result, including sync `get_state` on the async savers. - Three conformance tests whose task ids sort opposite to their task paths, so they cannot pass by chance: two on `get_delta_channel_history`, one on `get_tuple`'s `pending_writes` order. - SQLite migration tests from a pre-column database, sync and async, including a read-only file. One more that passes on main too: SQLite `setup()` while another connection holds the write lock, with no busy timeout, sync and async. It keeps the `ALTER` from waiting on that lock. Thanks to @ErenAta16 for the report, the reproduction, and for tracing `task_path` through all three backends, and to @ragnarok268 for the conformance-test design with task ids ordered opposite to their paths. --------- Co-authored-by: ErenAta16 <149434812+ErenAta16@users.noreply.github.com> Co-authored-by: ragnarok268 <58264829+ragnarok268@users.noreply.github.com>
LangGraph Python SDK
To help you ship LangGraph apps to production faster, check out LangSmith. LangSmith is a unified developer platform for building, testing, and monitoring LLM applications.
Quick Install
uv add langgraph-sdk
🤔 What is this?
This library provides the Python SDK for interacting with the LangGraph API. Use it to connect to a running LangGraph API server, manage assistants and threads, and stream runs from Python applications.
You will need a running LangGraph API server. If you're running a server locally using langgraph-cli, the SDK will automatically point at http://localhost:8123; otherwise, specify the server URL when creating a client.
📖 Documentation
For full documentation, see the API reference. For conceptual guides and tutorials, see the LangGraph Docs.
Quick Start
from langgraph_sdk import get_client
# If you're using a remote server, initialize the client with `get_client(url=REMOTE_URL)`
client = get_client()
# List all assistants
assistants = await client.assistants.search()
# We auto-create an assistant for each graph you register in config.
agent = assistants[0]
# Start a new thread
thread = await client.threads.create()
# Start a streaming run
input = {"messages": [{"role": "human", "content": "what's the weather in la"}]}
async for chunk in client.runs.stream(
thread["thread_id"], agent["assistant_id"], input=input
):
print(chunk)
Known Limitations
- WebSocket transport requires
websockets>=14and is only available on the async client (AsyncThreadStream). The sync client (SyncThreadStream) uses SSE exclusively. thread.extensions[name]opens a new subscription each time the same name is accessed. Assign the projection to a variable and reuse it within a single session rather than re-indexing across multiple iterations.- Sync streaming drives the lifecycle watcher in a background thread. Long-lived sync sessions will hold that thread open until the context manager exits.
- Reconnect attempts are limited to 5 by default for both the shared SSE fan-out and the lifecycle watcher. Persistent network partitions will surface as
RuntimeErroron in-flight projections.
Thread-Centric Streaming (v3)
client.threads.stream() returns a context manager that owns the SSE session for one thread. Typed projections — values snapshots, message streams, tool calls, custom events — all share the same underlying connection.
from langgraph_sdk import get_client
import asyncio
client = get_client()
async with client.threads.stream(
thread_id="my-thread",
assistant_id="agent",
) as thread:
await thread.run.start(input={"messages": [{"role": "user", "content": "hi"}]})
# Start all consumers concurrently so they share one SSE connection.
async def get_messages():
return [s async for s in thread.messages]
async def get_tool_calls():
return [c async for c in thread.tool_calls]
messages, tool_calls = await asyncio.gather(get_messages(), get_tool_calls())
for stream in messages:
print(await stream.text) # accumulated text
final = await thread.output # terminal state values
📕 Releases & Versioning
See our Releases and Versioning policies.
💁 Contributing
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
For detailed information on how to contribute, see the Contributing Guide.