← Back to Home

How LangGraph Interrupts Actually Resume

LangGraphHuman-in-the-loopAI Agent

TL;DR: interrupt() does not freeze execution in place and wait for you. It raises a GraphInterrupt exception; the runtime catches it, writes the current graph state to the checkpointer, and then ends that invocation. When you invoke again with Command(resume=...), the runtime re-runs the interrupted node from its first line — every statement before the interrupt() call executes a second time. That one rule drives everything else here: putting interrupt() inside a loop makes replay volume grow as a triangular number, and when parallel branches interrupt at the same time you must map resume values by Interrupt.id, because the UUID in StateSnapshot.tasks[].id throws a RuntimeError.

Pausing is not freezing: what `interrupt()` actually does

If you have used interrupt() before, there is a good chance your mental model is input(): the program stops on that line, you type something, and it continues from there. LangGraph does not work that way.

The official Interrupts page says:

"When an interrupt is triggered, LangGraph saves the graph state using its persistence layer and waits indefinitely until you resume execution."

The load-bearing sentence comes right after:

"The node restarts from the beginning of the node where the interrupt was called when resumed, so any code before the interrupt runs again"

So the mechanism is two steps:

1. Raise a special exception (GraphInterrupt) and let it propagate up the call stack until the runtime catches it.

2. Persist the current state, then return from invoke(), surfacing the payload through __interrupt__.

On the second invocation the runtime does not jump back to line 17. It calls the whole node function again, and only when it reaches interrupt() does it pull a value out of the resume list — by index. Code before interrupt() runs twice; code after it runs once.

I tested this the blunt way: increment a module-level counter at the top of the node body.

RUNS = {"n": 0}

def node(state):
    RUNS["n"] += 1
    ans = interrupt({"q": "approve?"})
    return {"v": 10 + int(ans)}
StageRUNS["n"]What happened
After first invoke1entered node → hit interrupt → paused
After Command(resume=True)3replayed the body (+1), then ran the tail after interrupt() returned

Hence the first hard rule, which the docs list under "Rules of interrupts" as "Side effects called before interrupt must be idempotent". Anything non-idempotent — sending mail, charging a card, writing a row — placed before interrupt() will fire twice.

The smallest loop that works

This is the minimal runnable example. Measured on langgraph 1.2.16 with langgraph-checkpoint 4.3.0.

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.memory import InMemorySaver

class S(TypedDict):
    v: int
    log: Annotated[list, operator.add]

def ask(state: S):
    ans = interrupt({"question": "approve?", "seen": state["v"]})
    return {"v": 10 + int(ans)}

g = StateGraph(S)
g.add_node("ask", ask)
g.add_edge(START, "ask")
g.add_edge("ask", END)

app = g.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "t1"}}

r = app.invoke({"v": 1, "log": []}, cfg)
print(r)

The return value carries an extra __interrupt__ key:

{'v': 1, 'log': [],
 '__interrupt__': [Interrupt(value={'question': 'approve?', 'seen': 1},
                             id='25d8aed8be448dfcebaa7f3bf3b4402c',
                             response_schema=None)]}

Note what that is: the entry state (v is still 1), not the node result — the node never finished. To tell whether the graph really paused, look at:

st = app.get_state(cfg)
st.next                             # ('ask',)  ← not done
[t.interrupts for t in st.tasks]    # pending Interrupt objects

__interrupt__ is fine when you are reading the invoke() return directly. In a service, the reliable signal is get_state(cfg).next plus st.tasks[].interrupts — see the parallel-branch section for why the __interrupt__ list order is not a contract.

And the second half:

r2 = app.invoke(Command(resume=5), cfg)
# {'v': 15, 'log': ['ask_done']}

Command(resume=5) becomes the return value of interrupt() inside the node, so v = 10 + 5 = 15.

Multiple interrupts in one node: matched by index, not by count

The docs are explicit about a node that calls interrupt() more than once:

"Whenever execution resumes, it starts at the beginning of the node. For each interrupt encountered, LangGraph checks if a matching value exists in the task's resume list. Matching is strictly index-based, so the order of interrupt calls within the node is important."

Measured behaviour with two consecutive calls:

def two(state):
    a = interrupt("first?")
    b = interrupt("second?")
    return {"log": [f"{a}/{b}"]}
ActionResult
First invokepaused, __interrupt__ holds only first?
Command(resume="A")paused, __interrupt__ now holds second?
Command(resume="B")done, log = ['A/B']

The takeaway: one resume feeds one interrupt. You cannot hand over both answers in a single call. I tried Command(resume=[1, 2]); the whole list became the return value of the first interrupt() and the second stayed pending:

app.invoke(Command(resume=["x", "y"]), cfg)
# log = ["['x', 'y']"]   ← the list is one value, not two

That wording trips people up because the documentation does talk about a "list of resume values" — but that list is the runtime's internal accumulator across rounds, not something you supply in one shot.

Loops: three answers, nine replays

Take the previous section one step further. If the loop body contains interrupt(), every resume replays all previous iterations.

The docs:

"a loop that calls interrupt() multiple times causes each resume to replay all previous iterations: the first resume replays 1 iteration, the second replays 2, and so on."

Measured, with a three-iteration loop that needs one answer per iteration:

after pause, body runs = 1
resume #1 -> body runs = 3
resume #2 -> body runs = 6
resume #3 -> body runs = 9

Three answers cost nine body executions. The official phrasing is "exponential re-execution"; what I actually measured is the triangular sequence 1 → 3 → 6 → 9 (increments of 1, 2, 3), which is quadratic rather than exponential. Either way the conclusion holds: cost grows super-linearly with the number of replies.

The fix is the pattern the docs recommend: keep the question in state, call interrupt() exactly once per invocation, and route back if the answer is invalid.

def collect(state: F):
    q = state.get("pending") or "age?"
    a = interrupt(q)
    if isinstance(a, int) and a > 0:
        return {"age": a, "pending": None}
    return {"pending": "invalid, retry"}

def route(state: F):
    return END if state.get("age") is not None else "collect"

Measured: after three resumes the node ran six times, each answer consumed exactly once, with no replayed history.

Parallel branches: map by interrupt id, not task id

This is the most counter-intuitive part. When two parallel branches each call interrupt():

r = app.invoke({"v": 1, "log": []}, cfg)
# __interrupt__ = [Interrupt(value='left?',  id='5e55830a...'),
#                  Interrupt(value='right?', id='c065a78a...')]

st = app.get_state(cfg)
st.next            # ('left', 'right')
[t.id for t in st.tasks]                        # UUID, with dashes
[i.id for t in st.tasks for i in t.interrupts]  # 32-char hex

Keying the dict by t.id blows up:

RuntimeError: When there are multiple pending interrupts, you must specify
the interrupt id when resuming.

Keying by i.id works:

m = {i.id: "OK-" + t.name for t in st.tasks for i in t.interrupts}
app.invoke(Command(resume=m), cfg)
# {'v': 1, 'log': ["left->'OK-left'", "right->'OK-right'"]}

The root cause is in the source. In langgraph/pregel/_loop.py, the test for "is this resume an id map" is:

if resume_is_map := (
    isinstance(resume, dict)
    and all(is_xxh3_128_hexdigest(k) for k in resume)
):

is_xxh3_128_hexdigest only accepts 32 hex characters. A dashed UUID fails, so the dict is treated as a plain scalar resume value, which then hits the "multiple pending interrupts" branch and raises.

Partial resumes are legal too. Supply a value for one id and the other stays pending:

partial -> {'v': 1, 'log': ["left->'only-left'"],
            '__interrupt__': [Interrupt(value='right?', ...)]}
next after partial = ('right',)

That is genuinely useful for batch approval: release one item and leave the other hanging until a person gets to it.

One more trap worth writing down: the order of __interrupt__ is not a contract. On the initial pause it was ['L', 'R'], but after a Command(update=...) triggered a replay, the same set came back as ['R', 'L'] — 8 runs, 8 times R first. So never infer "which node is asking" from position; only trust Interrupt.id.

`response_schema` does validate, but booleans sneak through as `"yes"`

In 1.2.x, interrupt() gained a response_schema argument:

def interrupt(value: Any, *, response_schema: dict[str, Any] | type | None = None) -> Any:

Pass a TypedDict and the runtime hangs the schema off Interrupt.response_schema:

response_schema = {'properties': {'approved': {'title': 'Approved', 'type': 'boolean'},
                                  'note': {'title': 'Note', 'type': 'string'}},
                   'required': ['approved', 'note'], 'title': 'Decision', 'type': 'object'}

Measured on resume:

Command(resume=...)Result
{"approved": True} (note missing)ValidationError: 1 validation error for Decision / note / Field required
42ValidationError: Input should be a valid dictionary
["a", "b"]ValidationError: Input should be a valid dictionary
{"approved": True, "note": "ok"}accepted

Here is the dangerous edge: {"approved": "yes", "note": "x"} is also accepted, and the node receives {'approved': True, 'note': 'x'} — the string "yes" was silently coerced to True.

So response_schema catches missing fields and wrong container types, but it does not catch type-adjacent strings. If your approval flow receives JSON from a form that serialises booleans as strings, response_schema will not flag it. Enforce it yourself with an explicit isinstance(ans, bool) check inside the node.

Subgraphs and cross-process resume

The 10-19 persistence article left one thing untested: what happens when the interrupt lives inside a subgraph (checkpoint_ns is non-empty). I picked that up here.

Measured: compile the subgraph on its own (no checkpointer), then attach it as a node to a parent graph that has a checkpointer —

invoke#1 -> {'v': 1, 'log': [], '__interrupt__': [Interrupt(value='sub-approve?', ...)]}
next = ('child',)
Command(resume=7) -> {'v': 17, 'log': ['done']}

The interrupt parks on the parent's child node, and a Command(resume=...) issued against the parent graph resumes it. The docs describe this "subgraph called as a function" pattern as:

"the parent graph will resume execution from the beginning of the node where the subgraph was invoked and the interrupt was triggered. Similarly, the subgraph will also resume from the beginning of the node where interrupt was called."

Note the word "similarly": both layers replay in full. Code before the subgraph call in the parent, and code before interrupt() inside the subgraph, both run again on resume.

The cross-process half is more interesting. I wrote two entirely separate Python processes that share nothing except one SQLite file and one thread_id:

proc1 (pause) : PAUSE_RESULT {'v': 5, 'log': [], '__interrupt__': [Interrupt(value={'q': 'cross-process?'}, ...)]}
proc2 (resume): BEFORE_NEXT ('ask',) {'v': 5, 'log': []}
               RESUME_RESULT {'v': 142, 'log': ['cross']}

Process B boots, reads next=('ask',) with v=5, and picks up exactly where process A stopped. That is what the docs call fault tolerance in practice: restart the service, rebuild the container, and the approval flow survives.

The flip side is a trap: resuming after the thread was deleted does not raise. After delete_thread(), get_state() returns values={}, and invoking Command(resume="late") runs the graph from the start and interrupts again. If your alerting depends on "resume fails loudly", this path is silent.

Troubleshooting: Six Errors I Reproduced Locally

Every traceback below was produced on this machine and copied verbatim.

Error one: `RuntimeError: When there are multiple pending interrupts, you must specify the interrupt id when resuming.`

Symptom: two parallel branches each call interrupt(); you build a dict keyed by get_state(cfg).tasks[].id and it explodes.

RuntimeError: When there are multiple pending interrupts, you must specify
the interrupt id when resuming. Docs: https://docs.langchain.com/oss/python/
langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation.

Cause: to be recognised as an id→value map, the dict must satisfy all(is_xxh3_128_hexdigest(k) for k in resume). A dashed UUID does not.

Fix: key on task.interrupts[].id instead.

Bonus finding: the documentation URL in that error message is a 404. https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop returns 404; the page that resolves today is https://docs.langchain.com/oss/python/langgraph/interrupts.

Error two: `UnboundLocalError: cannot access local variable 'resume_is_map' where it is not associated with a value`

Symptom: you pass Command(resume=None), or just Command() with nothing in it.

UnboundLocalError: cannot access local variable 'resume_is_map' where it is
not associated with a value

The cause sits in langgraph/pregel/_loop.py:

if (resume := cast(Command, self.input).resume) is not None:   # L1018
    if not self.checkpointer:
        raise RuntimeError("Cannot use Command(resume=...) without checkpointer")
    if resume_is_map := (...):                                  # L1026
        ...
...
if not writes and not resume_is_map:                            # L1038
    raise EmptyInputError("Received empty Command input")

When resume is None, the whole block at L1018 is skipped, so resume_is_map is never bound; reading it at L1038 raises immediately.

Knock-on effect: that friendly EmptyInputError("Received empty Command input") is unreachable on the resume=None path — the line meant to report the problem crashes first.

Fix: to continue an empty run, pass None as the first argument (app.invoke(None, cfg)), not an empty Command.

Error three: `ValidationError: 1 validation error for Decision` / `note` / `Field required`

Symptom: you declared response_schema=Decision with approved: bool and note: str, then resumed with only {"approved": True}.

ValidationError: 1 validation error for Decision
note
  Field required [type=missing, input_value={'approved': True}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.14/v/missing

Cause: note is required in the schema.

Fix: send a resume value that satisfies the whole schema, or make note optional with a default.

Error four: `InvalidUpdateError: At key 'v': Can receive only one value per step`

Symptom: you want to resume and mutate state in the same call, so you write Command(resume=good_map, update={"v": 77}).

InvalidUpdateError: At key 'v': Can receive only one value per step.
Use an Annotated key to handle multiple values.
For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/
errors/INVALID_CONCURRENT_GRAPH_UPDATE

Cause: the node's write triggered by resume and the update write land in the same step, so the non-reducer key v receives two values.

Fix: either give v a reducer (Annotated[list, operator.add] and friends), or defer the state change to the round after the resume completes.

Error five: `RuntimeError: Cannot use Command(resume=...) without checkpointer`

Symptom: you forgot checkpointer at compile time.

RuntimeError: Cannot use Command(resume=...) without checkpointer

Cause: interrupts depend on the persistence layer. Without a checkpointer there is no "suspend".

The nasty part is that the first half never complains. A graph compiled without a checkpointer calls interrupt() without raising; it just stuffs the Interrupt object into the invoke return value and finishes. You assume the flow is correct — and it only detonates at resume time.

Fix: pass a checkpointer instance at compile time, and judge suspension by get_state(cfg).next rather than by whether invoke threw.

Error six: `GraphInterrupt` swallowed by a bare `except Exception` (the one with no error at all)

This one produces no traceback, and it is the most dangerous of the six:

def node(state):
    try:
        ans = interrupt("trapped?")
    except Exception as e:
        return {"log": [f"swallowed:{type(e).__name__}"]
    return {"log": [f"got:{ans!r}"]}

Measured:

caught: GraphInterrupt (Interrupt(value='trapped?', id='4f62a0b8...'))
result -> {'v': 1, 'log': ['swallowed:GraphInterrupt']}
next = ()

The graph "finishes" cleanly, next is empty, and the human step is skipped entirely — your approval flow looks green while no human ever touched it.

The docs list this as rule number one: "Do not wrap interrupt calls in try/except", because pausing  the exception; catch it and there is no interrupt.

Fix: either move interrupt() outside the try block, or catch only specific types (except (ValueError, TypeError)). The latter is safe — I tested it, and after switching to specific types the interrupt parked normally with next = ('ok',).

When not to use `interrupt`

Limits of this test

FAQ

Q: Must the second invoke pass Command(resume=...)?

It depends. For a dynamic interrupt raised by interrupt(), yes — the interrupt point needs a value to return. For a static breakpoint created by compile(interrupt_before=["b"]), passing None (app.invoke(None, cfg)) is enough, because a breakpoint merely halts execution; it is not waiting for input.

Q: Static breakpoint or interrupt()?

The docs are blunt about it: "Static interrupts are not recommended for human-in-the-loop workflows. Use the interrupt function instead." Static breakpoints are a step-through debugging tool.

Q: How do I count how many interrupts are waiting?

Walk app.get_state(cfg).tasks; each task carries an interrupts tuple. The number of entries in __interrupt__ equals the total number of pending interrupts, but the order is not reliable (see the parallel branch section).

Q: Does response_schema block all bad input?

No. Missing fields, scalars and lists are all rejected with ValidationError, but approved: "yes" is silently coerced to True. That is stock pydantic lax-mode behaviour.

Q: How long does a human have to respond?

Whatever your checkpointer and cleanup policy allow. "Waits indefinitely" describes the runtime not imposing a timeout — it does not promise the thread will still be there later. Note that SqliteSaver.prune() raises NotImplementedError when called (recorded on 10-19); use delete_thread() to clean up.

---

The layer just below this one is "how state gets persisted at all" — see LangGraph 1.2.16 persistence, measured. The layer next to it is "what format you pay for when data crosses services" — see JSON vs MessagePack vs Protobuf, tested. This article covers the seam between them: once the state is saved, how a human gets attached to it without silently re-running half your node.

👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home