TL;DR
interrupt(payload) stops a graph in the middle of a node, hands payload to whoever is watching, and persists everything so the run can be resumed hours or days later with Command(resume=value) — at which point interrupt() returns that value and the node continues. It requires a checkpointer; without one there is nowhere to save the paused run and the call raises. The one behaviour that surprises everyone: resuming does not continue from the interrupt() line, it re-executes the node from the top, so every side effect above the interrupt() call happens twice. Put your writes, emails, and charges after the gate, or make them idempotent. This is the difference between a pause that is safe and a pause that double-charges a customer.
Simple explanation + analogy
Most "human approval" implementations are a polling loop: the program writes a row to an approvals table, returns, and something else checks every thirty seconds to see whether a human ticked the box, then tries to reconstruct where it was and carry on. You end up hand-rolling a state machine, and the reconstruction logic is where the bugs live.
interrupt() is a surgeon's timeout. The team is mid-procedure; someone calls a halt; everything stays exactly as it is — instruments in place, the state of the patient unchanged — until a decision comes back. Nobody re-preps the room. The pause is inside the operation, not between two operations.
That is the real shift. Your approval gate stops being a boundary between two separate programs and becomes a line of code in the middle of one function:
verdict = interrupt({"question": "approve this refund?", "amount": 240.0})
# execution stops here, possibly for days
# ...and resumes here, with verdict == whatever the human sent back
The analogy has one important limit, and it is the thing to remember about interrupt(). A surgical timeout freezes the room mid-motion. interrupt() does not freeze a Python stack frame — it cannot, because the process may exit entirely. It saves the state, and on resume it runs the node again from the beginning, feeding the saved answer to the interrupt() call when it comes back around. Everything before that line runs a second time.
Diagram
FIRST INVOCATION RESUME (a new call, maybe a new process)
---------------- ----------------------------------------
invoke({...}, cfg) invoke(Command(resume={...}), cfg)
│ │
▼ ▼
[assess] amount=240 -> needs_human [assess] (NOT re-run: already
│ │ committed to state)
▼ ▼
[human_gate] [human_gate] ◀── RE-ENTERED AT TOP
│ │
├─ audit_row_write() ← side effect #1 ├─ audit_row_write() ← #2 !!
│ │
├─ interrupt({...}) ├─ interrupt({...})
│ │ │ │
│ └─▶ raises GraphInterrupt │ └─▶ returns {"decision":
│ state saved to checkpointer │ "approved", ...}
▼ ▼
returns {"__interrupt__": [...]} [settle]
get_state(cfg).next == ('human_gate',) │
▼
END
The node body runs TWICE. Only ONE human decision was made.
Everything above interrupt() must be safe to repeat.
How it works (deep)
1. interrupt() throws on the way out and returns on the way back
The same call has two behaviours depending on whether a resume value is waiting for it.
- No resume value queued (first pass):
interrupt()raises aGraphInterrupt. LangGraph catches it, writes the current checkpoint, andinvoke()returns normally with an__interrupt__key in the result. This is not an error — a paused graph is a successful outcome. - A resume value is queued (after
Command(resume=...)):interrupt()returns that value as a plain Python object and execution continues past the line.
So you read it as an ordinary function call, but its control flow is exceptional on the first pass. That is why code after interrupt() never runs during the first invocation, and code before it runs on both.
2. A checkpointer is not optional
interrupt() needs somewhere to persist the paused run, so compile(checkpointer=...) is required and thread_id must be in the config. Which checkpointer determines how long the pause can last:
| Checkpointer | Pause survives | Use for |
|---|---|---|
InMemorySaver | the process lifetime | tests, demos, notebooks |
SqliteSaver | process restarts, single host | local apps, single-node services |
PostgresSaver | restarts, multiple hosts | production, horizontally scaled |
A human approval that must survive until Monday morning cannot use InMemorySaver. This is the most common reason a HITL prototype fails to become a product.
3. Detecting the pause and reading the request
Two ways to see that a run is waiting, and they answer different questions.
paused = app.invoke(initial, cfg)
"__interrupt__" in paused # did THIS call pause?
paused["__interrupt__"][0].value # the payload you passed to interrupt()
app.get_state(cfg).next # ('human_gate',) — is the thread waiting, at all?
__interrupt__ in the return value tells you about the call you just made. get_state(cfg).next is the durable question — it works from a different process, an API handler, or a dashboard listing every thread awaiting review. A finished thread has next == ().
The .value is whatever you passed to interrupt(), so pass a dict containing everything a reviewer needs: the item, the reason it was flagged, and the options. That payload is your API contract with the UI.
4. Resuming, and editing on the way through
Command(resume=value) sends value back to the waiting interrupt(). The value can be anything serialisable, which lets one gate express several outcomes:
app.invoke(Command(resume={"decision": "approved", "amount": 200.0}), cfg)
Here the reviewer both approved and edited the amount, and the node applies both. Approve, reject, and amend are the same mechanism with different payloads — you do not need separate graph paths for them. Note that invoke is called with the Command instead of an initial state; the state already exists on the thread.
The alternative is update_state(cfg, {...}) followed by invoke(None, cfg), which writes directly to state rather than answering the interrupt(). Prefer Command(resume=...) for approvals — it keeps the human's decision visible as data flowing through your node instead of an opaque state patch.
5. The re-execution trap, precisely
On resume, LangGraph replays the node from its start. Nodes that already completed in earlier super-steps are not re-run — their results are committed to the checkpoint. Only the interrupted node restarts.
So this is a bug:
def human_gate(state):
charge_card(state["amount"]) # runs on pause AND on resume: charged twice
verdict = interrupt({...})
return {"decision": verdict}
And these are the three fixes, in order of preference:
- Move the effect after the gate — the simplest correct answer. Charge in the node after approval.
- Move the effect into its own earlier node — completed nodes are not replayed, so an effect in a preceding node happens exactly once.
- Make it idempotent — key the write on something stable (
thread_id+ step) and make a repeat a no-op. Necessary when the effect genuinely must precede the gate.
A read-only computation before interrupt() is fine, just wasted work. It is writes that hurt.
6. Several gates, and interrupts in a fan-out
You can call interrupt() more than once in a graph; each pause resumes independently. If a node containing interrupt() is the target of a Send fan-out, every branch pauses, and __interrupt__ comes back as a list with one entry per branch — you resume them together. That is a genuinely useful shape for batch review, but be aware the re-execution rule applies per branch, so per-item side effects multiply by two as well.
7. When this stops applying
interrupt() pauses one thread. It does not give you review queues, assignment, reminders, SLA timers, or "if nobody responds in 48 hours, reject". A paused thread waits forever by default. Those are application concerns you build around the primitive — LangGraph tells you a thread is waiting and what it asked; it does not chase anybody.
The math
Human gates are a queueing problem, and the useful result is that latency is dominated by the human, so the only lever worth pulling is how many items you route to one.
N = items entering the pipeline
p = fraction requiring human review (0 <= p <= 1)
t_a = automated processing time per item
t_h = human decision time per reviewed item
R = number of reviewers working in parallel
human queue arrival rate lambda = N * p / period
reviewer service rate mu = R / t_h
stable only while lambda < mu
mean end-to-end latency T = t_a + p * (W_q + t_h)
where W_q is queue wait, which -> infinity as lambda -> mu
The design question is almost never "how fast is the model" — it is "what is p, and can R reviewers absorb it".
Worked example
N = 1000 refunds/day, auto-processing t_a = 2 s, human decision t_h = 90 s, R = 2 reviewers on an 8-hour shift.
reviewer capacity per day = R * 8h / t_h
= 2 * 28800 s / 90 s = 640 items/day
threshold at p = 0.50: reviewed = 500 -> 500 < 640 STABLE
threshold at p = 0.80: reviewed = 800 -> 800 > 640 BACKLOG GROWS
At p = 0.8 the queue grows by 160 items every day and no amount of model speed helps. Moving the auto-approve limit so that p = 0.5 is the fix; the graph is identical, only the routing predicate changes. That is the honest reading of a human gate: the threshold in your router is your capacity plan.
In the runnable example below the desk limit is $50, and the effect is visible with four items — one small refund flows straight through while the three above the limit each cost a human decision.
Real code
A refund pipeline with an approval gate, including a counter that proves the double-execution.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
from langgraph.checkpoint.memory import InMemorySaver
class RefundState(TypedDict):
amount: float
reason: str
decision: str
settled: str
side_effects: list[str] = []
def assess(state: RefundState) -> dict:
# Anything above the desk limit needs a person; below it, auto-approve.
return {"decision": "auto" if state["amount"] < 50 else "needs_human"}
def human_gate(state: RefundState) -> dict:
side_effects.append("audit-row-written") # runs again on every resume
verdict = interrupt({
"question": "approve this refund?",
"amount": state["amount"],
"reason": state["reason"],
})
return {"decision": verdict["decision"], "amount": verdict.get("amount", state["amount"])}
def settle(state: RefundState) -> dict:
return {"settled": f'{state["decision"]}:{state["amount"]:.2f}'}
def route(state: RefundState) -> str:
return "settle" if state["decision"] == "auto" else "human_gate"
g = StateGraph(RefundState)
for name, fn in [("assess", assess), ("human_gate", human_gate), ("settle", settle)]:
g.add_node(name, fn)
g.add_edge(START, "assess")
g.add_conditional_edges("assess", route, ["human_gate", "settle"])
g.add_edge("human_gate", "settle")
g.add_edge("settle", END)
app = g.compile(checkpointer=InMemorySaver()) # interrupt REQUIRES a checkpointer
# --- small refund: never pauses ---
cfg_small = {"configurable": {"thread_id": "small"}}
print("small :", app.invoke({"amount": 12.0, "reason": "late", "decision": "", "settled": ""}, cfg_small)["settled"])
# --- large refund: pauses mid-run ---
cfg = {"configurable": {"thread_id": "big"}}
paused = app.invoke({"amount": 240.0, "reason": "damaged", "decision": "", "settled": ""}, cfg)
print("paused:", "__interrupt__" in paused)
print("asked :", paused["__interrupt__"][0].value["question"], paused["__interrupt__"][0].value["amount"])
print("next :", app.get_state(cfg).next)
# The human answers, and may EDIT the payload on the way through.
done = app.invoke(Command(resume={"decision": "approved", "amount": 200.0}), cfg)
print("settled:", done["settled"])
# --- same graph, a rejection on its own thread ---
cfg2 = {"configurable": {"thread_id": "big2"}}
app.invoke({"amount": 900.0, "reason": "fraud", "decision": "", "settled": ""}, cfg2)
print("rejected:", app.invoke(Command(resume={"decision": "denied"}), cfg2)["settled"])
assert done["settled"] == "approved:200.00", done["settled"]
assert app.get_state(cfg).next == (), "graph reached END after resume"
# THE GOTCHA: the node restarts from the top on resume, so pre-interrupt lines re-run.
print("gated threads:", 2, "| audit rows written:", len(side_effects))
assert len(side_effects) == 4, side_effects
print("OK: each gated node body executed twice - once to pause, once to resume")
# Output:
# small : auto:12.00
# paused: True
# asked : approve this refund? 240.0
# next : ('human_gate',)
# settled: approved:200.00
# rejected: denied:900.00
# gated threads: 2 | audit rows written: 4
# OK: each gated node body executed twice - once to pause, once to resume
The last two lines are the point of the whole example. Two human decisions produced four audit rows. side_effects is a plain list here, but swap it for an INSERT, an email, or a payment capture and you have shipped a duplicate-writes bug that only appears on gated paths — which are, by construction, the expensive ones.
Note too that settled is approved:200.00, not approved:240.00. The reviewer amended the amount in the resume payload and the node applied it. Approve, deny, and amend all travelled through the same interrupt() call.
Real-world example
A guideline-drafting pipeline gates on scope before it spends money. Deciding the search strategy is cheap; executing it across several databases and then LLM-screening a few thousand abstracts is not. So a reviewer confirms the scope first, and only then does the expensive stage run.
The first version had the gate node do three things in order: write a "scope proposed" audit record, snapshot the draft scope to object storage, then interrupt() for approval. It passed every test, because the tests used InMemorySaver and resumed within the same function call, and nobody counted the audit rows.
In staging, each approved run produced two audit records and two storage objects with different timestamps. The audit trail — the entire reason for having a gate in a reviewed process — was showing every scope as having been proposed twice. Reviewers reasonably read that as the system having changed its mind and asked which version they had actually approved.
The fix took one line of thought and moved two: the audit write and the snapshot went into the node before the gate, and the gate node was reduced to nothing but interrupt() and returning the verdict. Completed nodes are not replayed, so both effects now happen exactly once. The lesson generalises past LangGraph — a resumable pause means the code around it must be replay-safe, and "replay-safe" is a property you design for, not one you get.
The second thing staging revealed was less subtle. InMemorySaver had been fine in tests; in staging a deploy restarted the service and every pending approval evaporated. Swapping to a database-backed checkpointer was a two-line change that should have been made on day one.
Interview questions companies actually ask
1. What does interrupt() do, and why does it need a checkpointer? [easy]
It pauses a graph mid-node, surfacing a payload to the caller, and resumes later with a value supplied by a human. It needs a checkpointer because the pause must outlive the function call — possibly the process — so the graph's state has to be persisted somewhere durable and keyed by thread_id. With no checkpointer there is nowhere to save the paused run.
2. How is this different from just returning and having a separate endpoint continue the work? [medium]
Returning makes the pause a boundary between two programs, so you hand-roll the state machine that reconstructs where you were, and you own the bugs in it. interrupt() keeps the pause inside one function: the graph state, the position in the topology, and the pending question are all persisted by the runtime, and resuming is one call. You write an approval as a line of code rather than an orchestration layer.
3. What exactly happens to the node body when you resume? [hard]
The interrupted node re-executes from the top. Nodes that completed in earlier super-steps are not re-run, since their updates are already committed, but the interrupted one restarts, and this time interrupt() returns the resume value instead of raising. So every statement above the interrupt() call executes a second time — a critical detail for anything with side effects.
4. Where should side effects go in a graph with a human gate? [hard]
After the gate, or in a node before it — never above interrupt() in the gate node itself. If an effect genuinely must precede the pause, make it idempotent by keying it on something stable like thread_id plus step, so the replay is a no-op. Read-only work before interrupt() is merely wasted, but writes get duplicated.
5. How do you find every thread currently waiting for a human? [medium]
get_state(cfg).next returns a non-empty tuple for a paused thread and () for a finished one, so you check it per thread — that is the durable signal, readable from any process. The __interrupt__ key in a return value only tells you about the invocation you just made, which is not enough to build a review queue.
6. How do you support approve / reject / amend without three graph paths? [medium]
Pass a structured value to Command(resume=...) — {"decision": "approved", "amount": 200.0} — and let the node interpret it. One interrupt() handles all three outcomes because the resume value is an arbitrary serialisable object, so the branching happens in your node logic rather than in the topology.
7. Command(resume=...) versus update_state() — when do you use which? [hard]
Command(resume=...) answers the waiting interrupt(), so the human's decision arrives as a return value inside your node — visible, typed, and part of the node's logic. update_state() patches graph state directly without answering the interrupt, which is right for correcting a bad value or forcing a path, but it makes the decision an opaque state mutation. Use resume for approvals.
8. What does LangGraph not give you for human-in-the-loop? [medium]
Queue management: assignment, reminders, escalation, timeouts, and audit of who decided what. A paused thread waits indefinitely with no notion of an SLA. interrupt() is the pause-and-resume primitive; the review workflow around it is your application's job.
9. Can you interrupt inside a Send fan-out? [hard]
Yes — each branch pauses and __interrupt__ returns one entry per branch, which you then resume. It is a reasonable batch-review shape, but the replay rule applies per branch, so any per-item side effect above the interrupt() line duplicates once per item rather than once overall.
When to use / tradeoffs
Reach for interrupt() when:
- An action is expensive, irreversible, or regulated, and a person must sign off first.
- The pause may outlive the request — minutes to days — and must survive a deploy.
- Reviewers need to edit the payload, not just approve or reject it.
- You need an auditable record of the state at the moment a human decided.
Do NOT use when:
| Situation | Why it breaks | Use instead |
|---|---|---|
| Every item needs review | The gate is not a gate; the graph is a UI queue | A task queue with the model as a suggestion source |
| The pause is sub-second | Checkpoint write per pause dominates the work | A synchronous confirmation in the caller |
| Reviewers need assignment, SLAs, escalation | interrupt() has no queue semantics; threads wait forever | A workflow/ticketing system driving the graph |
Using InMemorySaver in production | Pending approvals vanish on restart | SqliteSaver or PostgresSaver |
| Side effects must precede the gate | Node replay duplicates them | Move to an earlier node, or make idempotent |
Honest limits. The queueing model above assumes reviewers are interchangeable, decisions are independent, and t_h is roughly constant — all three are optimistic. In practice the hard items take far longer than the mean and arrive in bursts, so a system that looks stable at lambda < mu on daily averages still develops a backlog every Monday. The deeper limit is that a human gate transfers a correctness problem into a capacity problem: if p is high because the model is unreliable, adding reviewers hides the unreliability rather than fixing it, and the queue becomes a permanent operating cost. And a gate only buys accuracy if reviewers genuinely evaluate — a queue tuned for throughput teaches people to approve by reflex, at which point you are paying for review and getting a rubber stamp. Measure your reviewers' disagreement rate with the model; if it approaches zero, the gate has stopped working.
Summary + related articles
interrupt(payload)pauses inside a node, persists the run, and returns the human's value on resume viaCommand(resume=value).- It requires a checkpointer, and the checkpointer's durability sets the maximum length of the pause.
InMemorySavercannot survive a deploy. - Resuming re-executes the interrupted node from the top — completed nodes are not replayed, but everything above
interrupt()runs twice. - Therefore: side effects go after the gate or in an earlier node, or they must be idempotent.
get_state(cfg).nextis the durable "is this waiting?" check;__interrupt__only describes the call you just made.- One
interrupt()handles approve, reject, and amend, because the resume value is an arbitrary object. - The primitive gives you pause and resume, not a review queue — no assignment, reminders, or timeouts.
Related articles in this module (6-6):
- Checkpointers, Threads, and Durable Execution — the persistence layer that makes a pause survive a restart.
- Time Travel and State History — inspecting and rewinding the state a reviewer is looking at.
- Command and Dynamic Routing — the other half of
Command, for routing rather than resuming. - Dynamic Fan-Out with Send — what happens when a gated node is fanned out over a batch.
Related elsewhere:
- LangGraph — the base state/node/edge model and where
interrupt()fits in it. - Autonomous Agents — deciding how much autonomy to grant before a gate is needed at all.
- Production Agents — operating gated pipelines, including reviewer load.
Sources:
LangGraph docs: human-in-the-loop concepts ·
LangGraph how-to: add human intervention ·
LangGraph reference: interrupt and Command ·
Verified against langgraph 1.2.8; all output above is from an actual run.