LangGraph and Temporal are not alternatives at the same layer: a LangGraph checkpointer snapshots state at every super-step but needs something to call invoke(None, config) after a crash, while Temporal re-schedules the work itself from Workflow history. That leaves three shapes: a persistent checkpointer alone, the whole graph inside one Temporal Activity, or Temporal's LangGraphPlugin (temporalio-langgraph 0.1.0, Public Preview, shipped inside the SDK since temporalio 1.27.0) that runs each node as an Activity named graph.node or inline in Workflow code. Under the plugin every node needs an explicit execute_in, LangGraph retry_policy raises a ValueError in favour of Temporal's RetryPolicy, and the documented checkpointer is InMemorySaver, so your Postgres saver goes away. Each Activity node's input state becomes a payload in Workflow history, which caps it at 2 MB per payload, 4 MB per gRPC request and 51,200 events or 50 MB of history. Use the plugin for runs that take tens of minutes, call side-effecting tools or wait days on a reviewer; keep the checkpointer for short request-scoped agents. This week, measure your largest graph state in bytes before you write a line of plugin code.
You have a LangGraph agent in production with a Postgres checkpointer. It used to finish in thirty seconds. Now it runs for forty minutes across a pile of filings, calls tools that write to other systems, and sometimes waits two days for a reviewer to approve a draft. Somebody asks whether you should move it to Temporal, and the query "langgraph vs temporal" frames it as a choice between the two.
You do not. LangGraph is how you describe the agent: nodes, edges, state, interrupts. Temporal is a durable execution engine that decides what happens when the process running that description dies. The checkpointer and Temporal's event history both persist state, but they sit at different layers and make different promises, so the real decision is between three shapes: LangGraph with a persistent checkpointer and nothing else, the whole LangGraph run wrapped inside one Temporal Activity, or Temporal's LangGraphPlugin, which turns each node into its own Activity or runs it inline as Workflow code. (LangChain is a component library, not a runtime, so it is not part of this comparison.)
The plugin is not new. It shipped as temporalio.contrib.langgraph inside the Python SDK by temporalio 1.27.0 on 2026-04-30, and Temporal announced it in Public Preview in July. What changed in October is packaging: it moved into a standalone package, temporalio-langgraph 0.1.0, released on 2026-10-07 from the temporalio/ai-integrations repository, and the SDK's bundled copy became a forwarding import in temporalio 1.35.0. Everything below is read from source at those tags (temporalio-langgraph 0.1.0, temporalio 1.35.0, langgraph 1.2.19, Temporal server 1.32.x) and from the two projects' documentation. If you are still choosing an agent framework rather than a runtime, our LangGraph vs CrewAI vs OpenAI Agents SDK comparison is the place to start.
LangGraph checkpointer vs Temporal durable execution
The LangGraph checkpointer
A LangGraph checkpointer saves a snapshot of graph state at every super-step. When one node in a super-step fails, the nodes that completed in that step have their pending writes stored, so a resume does not re-run them. The LangGraph docs describe recovery precisely: call graph.invoke(None, config) with the same thread and it loads the latest checkpoint and retries unfinished work. Something has to call invoke(None, config). The checkpointer makes the run recoverable; it does not recover it. If the pod dies at minute 31 of a 40-minute run, the state is in Postgres, but no component notices the run stopped, and nothing re-schedules it. Your code, a cron sweep or your orchestration platform has to find abandoned threads and resume them. Per-node retries and timeouts belong to LangGraph's own retry_policy. LangGraph 1.2.19 also lets you choose when the checkpoint is written, through three durability modes: sync persists before the next step starts, async persists while the next step runs, and exit persists only when the graph exits. With exit, intermediate state is not saved, so you cannot recover from a process crash mid-execution. And InMemorySaver keeps checkpoints in RAM, so a restart loses all of them.
Temporal durable execution
Temporal records every step of a Workflow as events in a server-side history. When a worker dies, the server notices the missed timeout and schedules the work onto another worker, which replays the Workflow code against the history to rebuild its state and continues from the first step without a recorded result. Each Activity gets its own timeouts and RetryPolicy, and a Workflow can block on a signal for days without holding a process. Temporal's plugin announcement frames the gap in one line: "LangGraph checkpoints state, but checkpoints are not durable execution." That is the vendor's framing. Checkpoints do survive a crash; what is missing is that the run does not resume itself.
Three ways to run LangGraph with or without Temporal
Shape 1 is a persistent checkpointer, idempotent tools, and some process that re-invokes stalled threads. Shape 2 wraps graph.ainvoke in a single Temporal Activity so Temporal owns scheduling and retries for the run as a whole. Shape 3 is the LangGraphPlugin.
Shape 2 looks like the cheap way to add Temporal. An Activity is the unit Temporal retries. If a worker dies at node 25 of 30, Temporal retries the Activity, and the Activity starts the graph again from the top, re-running 24 nodes and their side effects. The only way to avoid that is to keep a persistent checkpointer inside the Activity and resume the thread on retry, at which point you are running shape 1 with Temporal as the scheduler. The Activity also needs a start_to_close_timeout longer than your slowest run, and a human approval wait inside it means a worker slot held for days. Shape 2 is a migration step, not a destination.
| 1. Checkpointer only | 2. Whole graph in one Activity | 3. LangGraphPlugin | |
|---|---|---|---|
| Crash recovery granularity | Last completed super-step | Whole run, unless it also keeps a checkpointer | Last completed node Activity |
| Who resumes the run | Your code calls invoke(None, config) | Temporal retries the Activity | Temporal re-schedules the node |
| Side-effect replay unit | Unfinished nodes of the failed super-step | Every node in the run, on each Activity retry | The one node whose Activity failed |
| Retries owned by | LangGraph retry_policy | Temporal, for the whole run | Temporal RetryPolicy per node; LangGraph retry_policy rejected |
| Human approval wait | Process exits; your system re-invokes later | Activity must stay alive or fail and restart | Durable Workflow wait on a signal, no process held |
| Streaming | All LangGraph stream modes | All modes, from inside the Activity | custom mode via streaming_topic; others need a bridge |
| Where state lives | Your checkpoint tables | Activity input and result, plus your checkpointer | Temporal history, plus External Storage if enabled |
| Size limits | Your database | 2 MB per payload on input and result | 2 MB per payload on every node's input and output |
| Ops cost | Postgres you already run | Temporal cluster plus Postgres | Temporal cluster; Postgres only for your own history |
How the Temporal LangGraph plugin works
For every node in a registered StateGraph, the plugin replaces the node's runnable with its own wrapper. A node marked execute_in: "activity" is registered as a Temporal Activity named <graph_name>.<node_name>, and the Workflow schedules it with workflow.execute_activity. A node marked execute_in: "workflow" runs inline in Workflow code. The plugin covers both the Graph API (StateGraph) and the Functional API (@entrypoint and @task).
The rules that bite on day one, all from _plugin.py and the README at tag python/langgraph/v0.1.0:
execute_in is mandatory on every node. Leave it off and registration raises ValueError. You cannot set it once in default_activity_options either; that raises its own ValueError. Functional API tasks set it in activity_options[task_name].retry_policy raises ValueError pointing you to Temporal's RetryPolicy in node metadata. Retry semantics move entirely to Temporal, which handles transient failures; replanning after a tool fails is still an agent decision, covered in our guide to agent tool failure recovery with retry, reroute and replan.workflow.execute_activity parameters (start_to_close_timeout, retry_policy, heartbeat_timeout, schedule_to_close_timeout, summary and the rest) become Activity options. Other keys stay visible to the node through config["metadata"].async def. A plain def makes LangGraph use run_in_executor, which Temporal's Workflow sandbox does not support.compile(store=...) is not reachable there; runtime.store is None and the plugin logs a warning once per run. Configure a database client on each worker instead.context_schema object crosses the Activity boundary, so the configured payload converter must be able to serialize it.interrupt() and the Functional API will not work, because LangGraph relies on context variables propagating through asyncio.create_task().Here is a graph with model and tool nodes as Activities carrying Temporal timeouts and retries, a pure merge step in the Workflow, and an async conditional edge:
from datetime import timedelta
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
from temporalio.common import RetryPolicy
from temporalio.langgraph import LangGraphPlugin
from temporalio.worker import Worker
class State(TypedDict):
messages: list
pending_tool_calls: list
async def call_model(state: State) -> dict: ... # network call: must be an Activity
async def run_tools(state: State) -> dict: ... # side effects: must be an Activity
def merge_findings(state: State) -> dict: ... # pure, deterministic: can run in the Workflow
async def should_continue(state: State) -> str: # conditional edges always run in the Workflow
return "tools" if state["pending_tool_calls"] else "merge"
g = StateGraph(State)
g.add_node("model", call_model, metadata={
"execute_in": "activity",
"start_to_close_timeout": timedelta(minutes=2),
"retry_policy": RetryPolicy(maximum_attempts=3),
})
g.add_node("tools", run_tools, metadata={
"execute_in": "activity",
"start_to_close_timeout": timedelta(minutes=5),
})
g.add_node("merge", merge_findings, metadata={"execute_in": "workflow"})
g.add_edge(START, "model")
g.add_conditional_edges("model", should_continue, ["tools", "merge"])
g.add_edge("tools", "model")
g.add_edge("merge", END)
plugin = LangGraphPlugin(
graphs={"review-agent": g},
default_activity_options={"start_to_close_timeout": timedelta(minutes=1)},
)
# worker = Worker(client, task_queue="agents", workflows=[ReviewWorkflow], plugins=[plugin])There is no retry_policy= argument on add_node: that is the LangGraph retry policy, and the plugin refuses it.
Do you need a Postgres checkpointer on Temporal?
The plugin's README is explicit: if your code requires a checkpointer, for example because it uses interrupts, use InMemorySaver; Temporal handles durability, so PostgreSQL or Redis checkpointers are not needed. The plugin's own interrupt and continue-as-new tests compile with InMemorySaver().
Under the plugin, LangGraph's Pregel loop, the code that decides which nodes run next and calls the checkpointer, runs in Workflow code. A Postgres checkpointer would issue network writes from inside the Workflow, which is exactly what replay forbids. Temporal's history is now the durable record, and the in-memory saver only exists so LangGraph's interrupt machinery has somewhere to keep state during a single replay.
That leaves a real gap if you used checkpoints as session history for a UI, an audit export or analytics. Issue 1803 on the Temporal Python SDK asks precisely this: the reporter ran agents with AsyncPostgresSaver for permanent session history and wanted the plugin for runs of up to an hour. The maintainer's reply opens with "Two untested ideas": stream LangGraph's updates and values streams through Temporal Workflow Streams to an Activity that writes to Postgres, or a custom saver that wraps InMemorySaver and mirrors each checkpoint write through an Activity. Treat both as suggestions, not supported patterns. The first also needs a manual astream() bridge, because streaming_topic only captures stream_mode="custom".
The approach the reporter of issue 1894 proposed as their leading fix is simpler and does not touch Workflow code: each node's Activity writes its own delta to Postgres before it returns. Because Temporal retries Activities, that write runs at least once, so key it on the Workflow run ID plus the Activity ID, both available from the Activity's info, and make it an upsert. A run that completes but silently lost its step history is a failure mode worth monitoring for; our post on detecting silent AI agent failure covers how to check the record rather than the run's own report.
If you stay on shape 1, none of this applies, and the work that matters is getting the saver right: connection pooling, PgBouncer, SSL and retention, covered in our LangGraph Postgres checkpointer production guide.
LangGraph interrupt and streaming on Temporal
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from temporalio import workflow
from temporalio.langgraph import graph
@workflow.defn
class ReviewWorkflow:
def __init__(self) -> None:
self.app = graph("review-agent").compile(checkpointer=InMemorySaver())
self.pending = None
self.decision = None
@workflow.query
def pending_review(self):
return self.pending
@workflow.signal
def submit_decision(self, decision: str) -> None:
self.decision = decision
@workflow.run
async def run(self, case: dict) -> dict:
config = {"configurable": {"thread_id": workflow.info().workflow_id}}
result = await self.app.ainvoke(case, config)
while "__interrupt__" in result:
self.pending = result["__interrupt__"][0].value
await workflow.wait_condition(lambda: self.decision is not None)
decision, self.decision, self.pending = self.decision, None, None
result = await self.app.ainvoke(Command(resume=decision), config)
return resultWhere the approval wait lives
interrupt() is supported in Activity nodes. At tag 0.1.0, the Activity catches LangGraph's GraphInterrupt and returns it in its output; the Workflow-side wrapper re-raises it, so LangGraph's loop sees an ordinary interrupt and ainvoke returns it under __interrupt__. The plugin does not provide the query, the signal or the wait. Those are yours, and Temporal's docs show the pattern: expose the pending item through a query, receive the reviewer's decision as a signal, and resume. workflow.wait_condition is a durable wait: no worker holds the run while the reviewer takes a weekend. The thread ID comes from the Workflow ID, which is deterministic across replays. One LangGraph rule carries over unchanged and matters more here. When you resume an interrupt, the interrupted node starts again from its beginning. Under the plugin that means the node's Activity is scheduled a second time, and every line before interrupt() runs again. If that code sends an email, opens a ticket or posts a draft to a document system, it does so twice. Move side effects after the interrupt() call or make them idempotent. Designing the approval itself (what the reviewer sees, escalation, timeouts) is a separate problem, covered in our guide to human-in-the-loop approval for AI agents.
Streaming
Setting streaming_topic on the plugin makes get_stream_writer() publish to a Workflow Stream topic, and the Workflow must construct WorkflowStream() in its __init__ or the interceptor raises at Workflow start. Activity nodes publish in batched signals every 100 ms by default; Workflow nodes publish synchronously. Two limits matter. The plugin wires up exactly one LangGraph stream mode, custom; messages, values, updates and debug need an astream() loop in the Workflow that republishes chunks. And delivery is at-least-once per Activity attempt: publishes from a failed attempt are not rolled back, so subscribers will see duplicates after a retry. Put a sequence ID in every chunk and dedupe on the consumer. The plugin docstring marks streaming as experimental.
Temporal payload size limits for LangGraph state
The mechanism comes straight from _activity.py: for every Activity node, the plugin builds an ActivityInput whose arguments are what LangGraph hands that node, which for a StateGraph node is its input state, plus a stripped, serializable copy of the config. The node's return value comes back as the Activity result. Both are written into Workflow history. So the size of your graph state sets the size of every node's payload, and the sum of those payloads sets how fast history approaches its limit. A 2 MB cap is per payload, and the 4 MB gRPC limit applies to the whole request, so a Workflow can hit it even when every individual payload is under 2 MB.
In issue 1894, the reporter's agents had 20 to 30 nodes. After dropping the Postgres checkpointer, they added one Activity at the end of the run that received the full accumulated final state to persist it. On a larger run that Activity failed to schedule:
BadScheduleActivityAttributes: ScheduleActivityTaskCommandAttributes.Input exceeds size limit.
The issue body says all 29 node Activities completed fine. The plugin's per-node payloads were not the problem in that run; the single end-of-run payload was. The reporter says the server terminated the Workflow (WORKFLOW_EXECUTION_TERMINATED), with all its completed work behind it. Temporal's docs describe termination as the behavior of older SDKs; for Python SDK 1.23.0 and later they say the SDK fails the Workflow Task with WORKFLOW_TASK_FAILED_CAUSE_PAYLOADS_TOO_LARGE and the Workflow stays open. Either way the run stops making progress, so alert on both outcomes.
The fixes, in the order to apply them:
cache() into the next run's graph(name, cache=...) and finished Activity nodes are not re-executed. Cache keys are built from each function's module and qualified name, so node and task functions must be defined at module level, outside __main__; otherwise registration raises ValueError.External Storage at temporalio 1.35.0 is configured on the data converter:
import dataclasses
import aioboto3
from temporalio.client import Client
from temporalio.contrib.aws.s3driver import S3StorageDriver
from temporalio.contrib.aws.s3driver.aioboto3 import new_aioboto3_client
from temporalio.converter import DataConverter, ExternalStorage
async def main() -> None:
session = aioboto3.Session()
async with session.client("s3") as s3_client:
driver = S3StorageDriver(
client=new_aioboto3_client(s3_client),
bucket="agent-payloads",
)
client = await Client.connect(
"temporal.internal:7233",
data_converter=dataclasses.replace(
DataConverter.default,
external_storage=ExternalStorage(drivers=[driver]),
),
)
... # run workers and start workflows here, while s3_client is openThree caveats. ExternalStorage and the S3 driver are both marked experimental, and Temporal's docs say the APIs may change before general availability. The same data converter must be configured on every client and every worker, or a worker will find a reference where it expected a payload. And it requires the temporalio[aioboto3] extra. Compression is not a fix: Temporal's docs call it a short-term measure that stops working as payloads keep growing.
The maintainer's answer on issue 1894 combined these: External Storage to clear the limit, then an Activity that copies the stored data to Postgres, or a custom External Storage driver that writes to Postgres directly to avoid storing it twice. A custom driver means subclassing StorageDriver and implementing name(), store() and retrieve().
| Limit | Value | Configurable |
|---|---|---|
| Single payload (Workflow or Activity input or result) | 2 MB | Self-hosted via limit.blobSize.error; fixed on Temporal Cloud |
| Payload size warning | 512 KiB | Self-hosted via limit.blobSize.warn |
| gRPC request, all payloads plus command metadata | 4 MB | Treat as fixed |
| Event history, warning | 10,240 events or 10 MB | Warning only, run continues |
| Event history, hard limit | 51,200 events or 50 MB | Self-hosted via limit.historyCount.error and limit.historySize.error |
Running LangGraph and Temporal on-premise
Everything in shape 3 can run inside your perimeter under MIT licenses: the Temporal server, the temporalio SDK, temporalio-langgraph and LangGraph. The LangGraph Agent Server, the langgraph-api package, declares Elastic-2.0, and you do not need it for any of the three shapes.
For a bank, insurer or hospital, the decision changes where agent state sits. With a checkpointer, it is rows in your Postgres, governed by the retention job and access controls you already have. With the plugin, every Activity node's input state and output is a payload in Temporal history, and anything offloaded is an object in the External Storage bucket. If a run touches patient records or loan files, those records are now in both places.
summary or summary_fn labels each scheduled Activity in the Temporal UI, which makes that history readable by a reviewer who does not know the graph. A summary_fn runs on every replay, so it must be deterministic and must not raise.Self-hosting also gives you the limit knobs. limit.blobSize.error defaults to 2 MiB in the server's dynamic config at 1.32.2 and can be raised per namespace; Temporal Cloud fixes it at 2 MB. Cloud also places history outside your network unless every payload is encrypted client-side by your codec. For External Storage on-prem, the S3 driver accepts any client that implements its client interface, so you can point it at an S3-compatible store your team operates or write a custom StorageDriver against storage you already run. Test either against your own store before relying on it.
LangGraph vs Temporal: which to pick
Our verdict: for long-running agents with side effects or approval waits, use the LangGraphPlugin and give up the Postgres checkpointer rather than trying to keep both; a Postgres saver called from Workflow code breaks replay anyway. For short agents, do not add Temporal at all; a checkpointer plus idempotent tools is the smaller system and fails in simpler ways. More patterns for building and running agents are collected on our AI agents pillar page.
This week, list every node in your graph. Mark each one that makes a network call, touches a file or writes to another system: those become Activities, and the rest can run in the Workflow. Mark every node with code before an interrupt() and check that it is safe to run twice. Then serialize the largest state object from a recent production run and measure it in bytes. If it is anywhere near 2 MB, decide between moving tool outputs out of state and enabling External Storage before you write a line of plugin code.
| Your situation | Pick | Why |
|---|---|---|
| Request-scoped agent, finishes in under a minute, read-only tools | 1. Checkpointer only | A failed request is retried by the caller; Temporal adds a cluster for no gain |
| Runs of tens of minutes with side-effecting tools | 3. Plugin | Per-node retry and recovery; a crash re-runs one node, not the run |
| Waits hours or days for human approval | 3. Plugin | Durable signal wait with no process held |
| Need Temporal scheduling now, cannot restructure nodes yet | 2. One Activity, with a checkpointer inside | Stopgap only; retries restart the graph without it |
| Graph state routinely over 2 MB, no appetite for External Storage | 1. Checkpointer only | Every Activity node pays the payload limit |
| Data must stay in-perimeter, team cannot operate Temporal | 1. Checkpointer only | Postgres you already run, under retention you already enforce |
| Data must stay in-perimeter, team can operate Temporal | 3. Plugin, self-hosted, with a payload codec | Full stack under MIT, tunable limits, encrypted history |
| Need a supported, GA integration contract today | 1, or 3 pinned with replay tests | The plugin is Public Preview at 0.1.0 |
FAQ
Quick answers to the questions this post tends to raise.



