← Back to Learning Hub

Human-in-the-Loop with interrupt()

LangGraphDurable ExecutionAdvanced20 min

By: Anacodic Team

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 a GraphInterrupt. LangGraph catches it, writes the current checkpoint, and invoke() 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:

CheckpointerPause survivesUse for
InMemorySaverthe process lifetimetests, demos, notebooks
SqliteSaverprocess restarts, single hostlocal apps, single-node services
PostgresSaverrestarts, multiple hostsproduction, 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:

  1. Move the effect after the gate — the simplest correct answer. Charge in the node after approval.
  2. Move the effect into its own earlier node — completed nodes are not replayed, so an effect in a preceding node happens exactly once.
  3. 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:

SituationWhy it breaksUse instead
Every item needs reviewThe gate is not a gate; the graph is a UI queueA task queue with the model as a suggestion source
The pause is sub-secondCheckpoint write per pause dominates the workA synchronous confirmation in the caller
Reviewers need assignment, SLAs, escalationinterrupt() has no queue semantics; threads wait foreverA workflow/ticketing system driving the graph
Using InMemorySaver in productionPending approvals vanish on restartSqliteSaver or PostgresSaver
Side effects must precede the gateNode replay duplicates themMove 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.


  • interrupt(payload) pauses inside a node, persists the run, and returns the human's value on resume via Command(resume=value).
  • It requires a checkpointer, and the checkpointer's durability sets the maximum length of the pause. InMemorySaver cannot 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).next is 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):

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.