You call interrupt(), the human approves, you resume with Command(resume=True), and the API call, email or charge that sits above interrupt() happens a second time. This is not a bug in your checkpointer. It is how LangGraph resumes, and it is documented. This guide reproduces it, explains it, and shows the fixes, with code we ran on langgraph 1.2.15 / langgraph-checkpoint 4.3.0 (Python), Oct 15, 2026.
Why it happens
From the LangGraph docs: on resume, "the runtime restarts the entire node from the beginning—it does not resume from the exact line where interrupt was called. This means any code that ran before the interrupt will execute again" (LangGraph interrupts). interrupt() pauses by raising a special exception; on the second run it finds the resume value in the checkpoint and returns it instead of raising.
So "the interrupt fires twice" usually means one of three things:
- Code before
interrupt()re-runs (the common one): logs, LLM calls, writes, charges. - You resumed on a different
thread_id. The docs: "Reusing it resumes the same checkpoint; using a new value starts a brand-new thread with an empty state." - You looped
interrupt()in one node (e.g. awhile Truevalidation loop), so each resume replays every earlier iteration. See our multiple interrupts guide.
Reproduce it, then fix it
CHARGES stands in for a payment API. No LLM, no API key.
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import START, END, StateGraph
from langgraph.types import Command, interrupt
class State(TypedDict, total=False):
amount: int
approved: bool
charge_id: str
CHARGES = [] # stands in for a payment API
def charge(amount):
CHARGES.append(amount)
return f"ch_{len(CHARGES)}"
# --- BUG: side effect before interrupt() ---
def approve_and_charge(state: State):
print("[node] start")
charge_id = charge(state["amount"]) # runs again on resume!
approved = interrupt({"question": f"Charge ${state['amount']}?"})
return {"approved": approved, "charge_id": charge_id if approved else ""}
def build_buggy():
b = StateGraph(State)
b.add_node("approve_and_charge", approve_and_charge)
b.add_edge(START, "approve_and_charge"); b.add_edge("approve_and_charge", END)
return b.compile(checkpointer=InMemorySaver())
# --- FIX: interrupt in its own node, side effect in the next node ---
def approval(state: State):
print("[approval] start")
return {"approved": interrupt({"question": f"Charge ${state['amount']}?"})}
def do_charge(state: State):
if not state["approved"]:
return {}
print("[do_charge] start")
return {"charge_id": charge(state["amount"])}
def build_fixed():
b = StateGraph(State)
b.add_node("approval", approval); b.add_node("do_charge", do_charge)
b.add_edge(START, "approval"); b.add_edge("approval", "do_charge"); b.add_edge("do_charge", END)
return b.compile(checkpointer=InMemorySaver())
print("== buggy ==")
g = build_buggy(); cfg = {"configurable": {"thread_id": "order-1"}}
out = g.invoke({"amount": 79}, cfg); print("paused:", out["__interrupt__"][0].value)
out = g.invoke(Command(resume=True), cfg); print("result:", out, "| charges:", CHARGES)
print("== new thread_id on resume ==")
CHARGES.clear(); g = build_buggy()
g.invoke({"amount": 79}, {"configurable": {"thread_id": "order-2"}})
try:
out = g.invoke(Command(resume=True), {"configurable": {"thread_id": "order-2-retry"}})
print("result:", out)
except Exception as e:
print("error:", type(e).__name__, e)
print("state of retry thread next:", g.get_state({"configurable": {"thread_id": "order-2-retry"}}).next)
print("== fixed ==")
CHARGES.clear(); g = build_fixed(); cfg = {"configurable": {"thread_id": "order-3"}}
out = g.invoke({"amount": 79}, cfg); print("paused:", out["__interrupt__"][0].value)
out = g.invoke(Command(resume=True), cfg); print("result:", out, "| charges:", CHARGES)
Real output (pip install langgraph==1.2.15):
== buggy ==
[node] start
paused: {'question': 'Charge $79?'}
[node] start
result: {'amount': 79, 'approved': True, 'charge_id': 'ch_2'} | charges: [79, 79]
== new thread_id on resume ==
[node] start
[node] start
error: KeyError 'amount'
state of retry thread next: ('approve_and_charge',)
== fixed ==
[approval] start
paused: {'question': 'Charge $79?'}
[approval] start
[do_charge] start
result: {'amount': 79, 'approved': True, 'charge_id': 'ch_1'} | charges: [79]
What this shows:
- Buggy:
[node] startprints twice and the customer is charged twice (charges: [79, 79]), even though the human approved once. - New
thread_idon resume: LangGraph has no paused task onorder-2-retry, so it starts that thread fromSTARTwith empty state and the node crashes withKeyError: 'amount'(in your app it may instead silently start a fresh run and interrupt again). Always resume with the exactthread_idyou paused on, and store it with the pending approval. - Fixed: the approval node still re-runs (
[approval] starttwice, which is expected and harmless because it has no side effects) butdo_chargeruns once:charges: [79].
The three fixes
- Move the side effect into the next node (shown above). The node holding
interrupt()should only ask and return the answer. This is the docs' recommended "separate side effects into separate nodes." - Put the side effect after
interrupt()in the same node. Code after the call only runs once the resume value exists. - Make unavoidable pre-interrupt work idempotent: upserts instead of inserts, and an idempotency key (e.g. derived from
thread_id+ tool call id) on payment or email APIs, so a replay is a no-op.
Also check: never wrap interrupt() in a bare try/except Exception, which swallows the pause; and use a durable checkpointer (SQLite/Postgres) in production, since InMemorySaver is lost on restart (durable savers were not tested here).
Tested starters
- Free: LangGraph human-in-the-loop approval graph, propose → approval → execute, with real run output.
- Pro: Idempotent approval: no double charges on resume, the idempotency-key pattern, in Signal Pro ($7/month or $69/year).
- Done for you: the $79 Agent Approval-Flow Fix. We repair approval and resume in your LangGraph agent and deliver a tested patch within 48h, or a full refund.