skip to content

How do LangGraph's interrupt_before and interrupt_after differ from interrupt()?

level: middleimportance: should knowfreq 55%

answer

  1. Configuration versus a call in code
  2. Boundary breakpoint, no payload
  3. None means continue, not new input
  4. Before edits input, after reviews output
  5. Conditional gating needs the dynamic one

basics

~20 s

interrupt_before and interrupt_after are static pauses configured on compile: they stop the graph at a node boundary, carry no payload, and resume with invoke(None, config). interrupt() is dynamic — it lives in node code, can fire conditionally, ships a payload, and resumes with Command(resume=...).

solid answer

~50 s

Static interrupts are declared when you compile: `builder.compile(checkpointer=saver, interrupt_before=["tools"], interrupt_after=["plan"])`. They are node-boundary breakpoints. `interrupt_before` stops the run *before* the named node executes, so the node's input state is still editable; `interrupt_after` lets the node finish and commit its update, then stops, which is what you want for reviewing a produced artifact. Neither carries a payload — the caller inspects the checkpoint via `get_state` to see what is pending — and you resume by calling `graph.invoke(None, config)`, where `None` means "add no new input, just continue". Dynamic `interrupt()` is a call inside node code: it can fire only for risky inputs, it hands the reviewer a structured payload, and it is resumed with `Command(resume=value)` whose value flows back into the node. Both require a checkpointer. In LangGraph 1.x, static interrupts are positioned mainly as a debugging aid; `interrupt()` is the recommended production human-in-the-loop mechanism.

code

python · 14 lines
python
graph = builder.compile(
    checkpointer=saver,
    interrupt_before=["tools"],
    interrupt_after=["draft"],
)

config = {"configurable": {"thread_id": "t-2"}}
graph.invoke({"topic": "quarterly report"}, config)

snapshot = graph.get_state(config)
print(snapshot.next)      # nodes about to run
print(snapshot.values)    # state as checkpointed

graph.invoke(None, config)  # continue; None = add no new input

go deeper

for a junior

Know there are two ways to pause: names listed at compile time, and a call written inside a node. Be able to say the compile-time one stops at node boundaries and the in-code one can send the reviewer a message.

for a middle

Be ready to contrast them precisely: payload versus no payload, Command(resume=...) versus invoke(None, config), unconditional versus conditional, and why interrupt_before leaves the node's input still editable while interrupt_after does not.

for a senior

Show judgment about gate placement — an approval over an irreversible action belongs before the node, and gating every call trains reviewers to rubber-stamp. Mention that static interrupts are a debugging tool in current LangGraph and explain the reasoning, not just the recommendation.

for a principal

Own the policy layer: which classes of action warrant a pause at all, how the decision vocabulary is versioned as the graph evolves, and why a topology-coupled breakpoint list is a fragile place to encode product-level approval rules.

## Two mechanisms, one runtime LangGraph can stop a run in two ways, and interviewers like this question because the choice reveals whether you have actually built an approval flow or only read the tutorial. **Static interrupts** are configuration. You list node names when compiling the graph: `graph = builder.compile(checkpointer=saver, interrupt_before=["tools"], interrupt_after=["draft"])` Both parameters take a list of node names, and both accept the wildcard `"*"` to break on every node. The runtime consults that list at each superstep boundary and stops the run there. Nothing in your node code knows this is happening — the pause is invisible to the function. **Dynamic interrupts** are code. A node calls `interrupt(payload)` from `langgraph.types`, which raises internally, checkpoints, and returns the payload to the caller under the `__interrupt__` key. ## Before versus after The two static flavours answer different questions. `interrupt_before=["tools"]` means: the tool node has *not* run. The last thing in state is the model's proposed tool call. This is the approval-gate position — you can reject the action, or rewrite the arguments with `update_state` before letting it proceed, because the side effect has not happened yet. `interrupt_after=["draft"]` means: the node ran, its state update was committed to the checkpoint, and the graph stopped before scheduling anything downstream. This is the review-the-output position — you look at what was produced and decide whether the run should continue. It is the wrong place for an approval gate over a side effect, because by the time you are looking, the node already did the work. ## Payload and resume semantics Static interrupts carry no information. The caller has to ask: `snapshot = graph.get_state(config)` and read `snapshot.next` (the node names about to run) and `snapshot.values` (the state) to work out what is pending. You then resume with `graph.invoke(None, config)`. The `None` is the important part: it means "no new input", continue the paused thread from its checkpoint. Passing a dict there would instead start a fresh superstep with that input applied, which is a common way to corrupt a run. Dynamic interrupts are self-describing. The payload you pass is exactly what the approval UI receives, and the resume value you send with `Command(resume=...)` is exactly what the `interrupt()` call returns inside the node. That round trip is why dynamic interrupts compose with real product flows: "approve", "reject with this reason", "use these edited arguments" are all just values. ## Conditionality A static interrupt fires every time the named node is reached. If only 3% of tool calls are risky, `interrupt_before=["tools"]` gates 100% of them and trains reviewers to click approve without reading. Dynamic `interrupt()` sits inside an `if` — you inspect the proposed action, decide whether it crosses a risk threshold, and only then pause. Risk-proportional gating is almost always what production wants, and it is only expressible dynamically. ## Granularity Static interrupts are node-granular by construction: the smallest thing you can pause at is a node boundary. If you want a pause in the middle of a node — after computing a plan but before executing it — your only options are to split the node in two or to call `interrupt()` where you want the pause. That is also the reason a dynamic interrupt forces a node replay on resume, while a static one does not: the static pause happens *between* nodes, so nothing needs re-executing. ## What both share A checkpointer is required either way, and a `thread_id` in `configurable` is required either way — the pause is stored state, and the thread id is the handle. Neither mechanism holds a thread, a socket or a coroutine open. Both are durable across process restarts if the checkpointer is. ## Choosing Use static interrupts while developing: `interrupt_before="*"` turns the graph into a step debugger you can single-step with `invoke(None, config)`, inspecting state between nodes. Use `interrupt()` for anything a user will see: it is conditional, it carries structured context, it accepts a structured answer, and its behaviour does not depend on graph topology staying the same. In LangGraph 1.x the documentation itself steers production human-in-the-loop toward `interrupt()`, with static interrupts framed as a debugging convenience. Saying that plainly, and explaining *why* the payload round trip is the difference that matters, is what a strong answer sounds like.

  • Why is invoke(None, config) rather than invoke(state, config) the correct way to resume a static interrupt?
    `None` tells LangGraph to add no new input and simply continue the thread from its checkpoint. Passing a state dict instead applies that dict as fresh input through the channel reducers and starts a new superstep, which typically duplicates or overwrites data and can re-enter the graph at the entry point rather than continuing where it stopped. If you need to change state before resuming, use `update_state` and then resume with `None`.
  • When would interrupt_after still be the right choice over a dynamic interrupt?
    When you are debugging and want to inspect exactly what a node committed, or when every output of that node genuinely needs review with no conditionality and no structured answer — a simple continue/abort gate. Compiling with `interrupt_after=["node"]` gives that in one line with no changes to node code, which also makes it easy to turn off. Anything needing a payload, a typed decision, or risk-based triggering belongs in `interrupt()`.
  • Can you combine both mechanisms in one graph?
    Yes. They are independent: the compile-time lists are checked at node boundaries while `interrupt()` fires from inside node bodies, and both write to the same checkpointer on the same thread. A practical mix is dynamic interrupts for the product-facing approval gates plus `interrupt_before` on a suspect node while you are debugging it. Just remember the resume forms differ — `invoke(None, config)` for the static pause, `Command(resume=...)` for the dynamic one.

saying these in an interview costs you the question

  • Says static interrupts can carry a payload to the reviewer
  • Resumes a static interrupt by re-passing the state dict
  • Uses interrupt_after as a gate before an irreversible action
  • Thinks interrupt_before can fire only for risky inputs
  • Claims static interrupts work without a checkpointer

context