LangGraph publishes three different defaults for recursion_limit, and only one of them is wrong -
which is not the same as only one of them being right.
The source says 10007. The Graph API documentation says "starting in version 1.0.6, the default
recursion limit is set to 1000 steps", while _config.py at the 1.0.6 tag reads 10000, so that
sentence is out by a factor of ten about the version it names. The Platform SDK's Config schema
says 25.
That third one is a different surface, and saying so is the whole point of this article. The SDK default governs runs submitted to the Platform; the library default governs a graph you invoke in your own process. Two of those three numbers describe different things, and only one pair is a genuine contradiction. Working out which is the job.
Part 6 of the measurement series got as far as the first two and filed the third as "the historical value older material still quotes". That was one scope short. It is not historical - it is the live default on a surface the library user never touches, which is a different and more useful thing to know.
I wrote a book about LangGraph. It says 10007, which is the value in the source, and it has said
so for longer than the documentation has been wrong about it. That is the good news. Then I
stopped reviewing the book and started measuring it.
The errors that survive review are scope errors
Here is the claim this article owns.
In a fast-moving framework, the wrong sentences that survive expert review are almost never false. They are true at a scope nobody wrote down.
Two things survive review, not one. Systems fail by composition, which is what the panel found when it discovered Atlas had never run end to end. Sentences fail by scope. Composition failures at least announce themselves the moment anyone runs the whole thing; scope errors are still sitting there afterwards, reading correctly, in a book that has been through three audits. They hold for the case the author had in mind, fail for the case the reader is in, and no amount of re-reading catches the difference, because re-reading checks whether a sentence is true and the defect is in the sentence's silent quantifier.
The consensus belief I am arguing against is the comfortable one: that a technical book about a fast-moving framework goes wrong by going stale. Versions move, APIs get renamed, the text rots. Everyone budgets for that failure, and it is the one that had not happened yet.
Be careful with that evidence, because it is weaker than it looks. The currency pass ran in July, weeks after the last chapter commit, and found zero drift. A staleness audit run that soon finding nothing is the expected result, not a refutation of anything. What it does establish is the part I care about. Before staleness had any time to happen, the errors that had already survived expert review were scope errors. Staleness is the failure that arrives later, and it is the one you already have a process for.
What did happen is that two claims turned out to be scoped to a case I had not stated, one whole subsystem was never exercised end to end, and then the pattern kept going: the audit that caught the first scope error committed one of its own while writing the correction down, and the one-word fix I drafted for that turned out to be scoped as well. Three layers, each written by someone who by then knew exactly what to look for. That last part I did not expect.
Three audits, three different failure classes
Building Real-World Agentic AI Systems with LangGraph is 27 chapters, 7 appendices, and one support assistant called Atlas built end to end - triage, retrieval, tools, a refund path behind a human approval gate. It has been audited three times, by three methods that do not overlap.
| Audit | Method | What it found |
|---|---|---|
| Currency pass | Re-verify every time-sensitive claim against the web | Zero drift. It also declined to chase a newer patch release, keeping the pin as a deliberate snapshot |
| Five-expert panel | Five adversarial expert personas, one pass each, prompted to break rather than approve | Atlas had never answered a customer question, in the book or in the repo. Middleware built across three chapters existed and none of it executed in the compiled graph |
| Measurement series | 77,568 words of probes against the running framework | 2 contradictions, 5 omissions, 0 dangerous errors |
All three rows are self-assessed, which is worth saying before I lean on them. Every chapter was reviewed. Every code listing ran in isolation. The test suite passed. And the composition of those correct parts had never been executed, so nothing in the review process was looking at the thing that was broken.
That is a composition failure. Review is bad at those, because every part it looks at is correct. The third row is the failure this article is about. The fix took the companion suite from 353 tests to 379.
The wrong way: driving a resume loop with snapshot.next
Chapter 9 of my book shipped with this comment on the snapshot API:
snapshot.next # node(s) pending; empty tuple () means the run finishedThat sentence is true. For the scenario the chapter is about, a process that died mid-run, it is
exactly right, and the surrounding prose calls .next the resumption boundary made visible.
Now carry it into the other place a LangGraph thread pauses, which is a human approval gate, and write the obvious driver:
from langgraph.types import Commandconfig = {"configurable": {"thread_id": "t-1"}}app.invoke(inputs, config)snapshot = app.get_state(config)while snapshot.next: decision = ask_a_human(snapshot) app.invoke(Command(resume=decision), config) snapshot = app.get_state(config)print("run complete")Now measure it. This is langgraph 1.2.6, the version the book pins:
PROBE A - one interrupt() in the node after first invoke (paused) next=('gate',) interrupts=1 after resume (finished) next=() interrupts=0PROBE B - two interrupt() calls in one node after first invoke (pause 1) next=('gate',) interrupts=1 after resume #1 (pause 2) next=() interrupts=1Look at the last line. The graph is paused, holding a pending interrupt, waiting for a second
answer. snapshot.next is empty. The loop above exits, prints run complete, and the node's work
never finishes. No exception. No warning. The final state reads like a clean run.
The condition is narrower than it looks: a node containing more than one interrupt() call,
from the first resume onward - which turned out to be a proxy rather than the condition, and
probe H below breaks it. On a single-interrupt node at first pause the interrupting node stays in
.next and the loop behaves. So the bug is invisible in every simple example, including the ones
in my book, and it shows up on the multi-gate approval flows that are exactly the systems careful
enough to be driving a graph in a loop.
The fix I reached for first, and why it was worse
The obvious repair is one word:
while snapshot.interrupts:StateSnapshot.interrupts is documented as "interrupts that occurred in this step that are
pending resolution", which is the question the loop is actually asking. I wrote that down, felt
good about it, and then ran five more probes before publishing. Same version, five graph shapes.
PROBE C update_state() on a paused thread next=('gate',) interrupts=0PROBE D one interrupt() inside a subgraph next=('child',) interrupts=1PROBE E static breakpoint, interrupt_before next=('gate',) interrupts=0PROBE F node raised, ERROR write on the task next=('gate',) interrupts=0PROBE G two parallel gates, one answered next=('gb',) interrupts=2PROBE H one interrupt, resumed, then raised next=() interrupts=1Probes E and F are the ones that matter, and I did not think to run them until a reviewer asked.
A thread paused at a static breakpoint has real pending work and zero interrupts. A thread
whose node raised has real pending work and zero interrupts. That is the crash-recovery case my
own book chapter is about, and my improved loop never enters it. It prints run complete over an
abandoned thread, which is the exact failure I had just spent two sections condemning.
All eight cases. The last two columns are the predicate read at that state, not a loop driven to termination:
| Case | .next | .interrupts | .next predicate | .interrupts predicate |
|---|---|---|---|---|
one interrupt(), paused | ('gate',) | 1 | ok | ok |
two interrupt() in a node, after resume 1 | () | 1 | no | ok |
update_state() on a paused thread | ('gate',) | 0 | ok | no |
| static breakpoint | ('gate',) | 0 | ok | no |
| node raised, no interrupt | ('gate',) | 0 | ok | no |
one interrupt() in a subgraph | ('child',) | 1 | ok | ok |
| two parallel gates, one answered | ('gb',) | 2 | ok | ok, but stale |
one interrupt(), resumed, then raised | () | 1 | no | ok, but stale |
.next fails two cases in eight. My replacement fails three. I proposed a one-word fix for a
scope error and drafted a wider one.
The last row is the one that should bother a production reader, and it is the shape of my own
book's running example: a human approves a refund, the payment call throws. The node holds a
resume write and an error, real work is outstanding, and .next is empty. Retrying with
invoke(None, config) completes it and the approval survives - but only if something told you to
retry.
The condition is not the interrupt count
While running probe G I also found that my "two or more interrupts in one node" rule was a proxy,
not the mechanism. Finish the source read from earlier and it falls out. ERROR and INTERRUPT
writes are skipped; RESUME is not. So a task that has taken a resume write and has not
finished carries writes, and if not t.writes drops it out of .next while it still holds work.
Probe H is what discriminates the two. That node has exactly one interrupt(), so the
interrupt-count rule predicts it is fine. It is not: it resumes, raises, and leaves .next empty
with work outstanding. The count was never the condition. A task that has taken a resume write and
has not finished is, and two interrupts in one node was just the smallest arrangement that let me
observe it.
Probe G is consistent with that and proves nothing on its own, which is worth saying because I originally wrote it up as the decisive test. Both explanations predict its output identically.
And the whole finding was quantified over an accessor I never named
A reviewer asked which snapshot accessor the probes used. All of them use get_state(config) on a
bare thread id. So I ran the other one at probe B's paused state:
get_state : next=() interrupts=1get_state_history[0]: next=('gate',) interrupts=1Same thread, same checkpoint, same field, two answers. _prepare_state_snapshot folds pending
writes in only when apply_pending_writes is set; get_state sets it when you have not pinned a
checkpoint_id, and get_state_history never sets it at all. So .next empties through one
accessor and does not through the other.
The obvious next thought is to poll the history accessor instead and be done. That trades one
scope error for another. apply_pending_writes gates the apply_writes call as well, so the
snapshot you get back has the correct .next and channel values that do not yet reflect the
writes the thread has taken. Right answer about what runs next, stale answer about what the state
holds.
Nothing I measured is wrong. But the correct statement of this finding is "get_state(config),
with no checkpoint_id pinned, on a task carrying a resume write that has not completed" - and
until someone asked, I had written down the second half of that and not the first. Fourth layer,
same shape, in the article that exists to point at the shape.
What to actually do: branch on both signals
There is no single field to poll. If you must drive a graph generically, branch:
snapshot = app.get_state(config)while snapshot.next or snapshot.interrupts: if snapshot.interrupts: # map form, not scalar: a scalar resume raises when more than # one interrupt is pending answers = {i.id: ask_a_human(i) for i in snapshot.interrupts} app.invoke(Command(resume=answers), config) else: app.invoke(None, config) # crash, error, or static breakpoint snapshot = app.get_state(config)Then I ran it, because every probe so far had read a field at one sampled state and none had driven a loop. Against row eight's graph:
iter 1: next=('gate',) interrupts=1 -> resume branch asked a human (call 1) -> 'yes' invoke raised: payment provider timed outiter 2: next=() interrupts=1 -> resume branch asked a human (call 2) -> 'yes-again-2'loop exited after 2 iterationshumans asked : 2 ['yes', 'yes-again-2']final values : {'answers': ['yes', 'done']}It terminates and the run completes, which is the good news. It also asks a human to approve the
same refund twice, and throws the second answer away - the mapped resume lands after the cached
one and interrupt() reads index zero. invoke(None, config) was the correct call on iteration
two and the loop never reaches it, because the interrupt write survives on a checkpoint that never
advanced.
That convicts .interrupts on a softer version of the charge I brought against .next. Its
docstring says "interrupts that occurred in this step that are pending resolution". The interrupt
did occur in the step being reported; it is no longer pending resolution.
Row seven has a cheaper version of the same problem. .interrupts reports 2 there when only one
gate is genuinely outstanding, and the loop asks a human about every entry it finds, so that row
also re-prompts for an approval already given - it just delivers the remaining answer correctly
afterwards.
So: progresses and terminates on all eight, and takes the branch a human would want on six or
seven of them. I drove row eight end to end; on the others the branch is derived from the sampled
predicate and that row's measured single-step outcome, and row three is the one I am least sure
of, because invoke(None, config) on a thread waiting for a human re-runs the node before the
next iteration gets round to asking. I am not claiming the loop is complete. The last two
prescriptions in this article were not either.
One more caveat on the probe above: the fault it models is transient, so the retry succeeds. A node that fails permanently keeps taking the resume branch and re-prompting a human every iteration.
Better, avoid needing it:
-
One
interrupt()per node, and chain the nodes. The maintainers recommend this themselves. LangGraph issue #6208 is open on the related re-execution hazard, where a maintainer states that a node with two interrupts re-runs after only one resume. That re-execution is the more dangerous half for most systems: the series measured a node body runninginterrupts + 1times, so any side effect above the firstinterrupt()fires once per gate and once more. Chaining removes both problems. The shippedHumanInTheLoopMiddlewarealready batches gated calls into a single interrupt, so if your shape fits it you get this without writing anything. -
Use the map form
Command(resume={interrupt_id: value})when more than one task is paused at once, which is the probe G shape. A scalar resume is rejected outright once two interrupts are pending, and the map binds a value to a specific task.I first wrote that bullet claiming the map form also protects a node with two gates against reordering. It does not. The interrupt id comes from the task namespace, not the call site, so both
interrupt()calls in one node carry the same id - I measured0fbf5f30de6403a3bc5a54bc249b88effor both. One key, one slot. Inside a node the map form is positional exactly as the scalar form is, and there is no id-based remedy at all. Chaining is the only fix, which is why it is the maintainers' advice rather than a workaround. -
Do not mutate a paused thread mid-loop. If operator tooling calls
update_state()on a thread a driver is polling, the driver's view of pending work is no longer trustworthy.
All of this is measured on the in-process OSS API. The LangGraph Platform SDK exposes its own
state surface and carries an open report of the inverse failure, interrupts arriving empty while
__interrupt__ streams correctly. If you are on Platform, probe before trusting any of it.
When a correct guarantee is scoped to the wrong unit
I coined a name for this shape in
Part 8 of the measurement series, and I am
going to use that one
rather than invent a second one. A Scope-Blind Guarantee is a runtime guarantee that is real,
correctly implemented and correctly enforced, whose unit of scope differs from the unit the
adopting engineer assumes, and which appears on no artifact the framework renders that would
correct the assumption. All three properties are required. The -Blind refers to the artifact,
not the reader and not the mechanism.
.next qualifies, and the third property is where it qualifies hardest. Its docstring reads "the
name of the node to execute in each task for this step", which sounds like a clean statement of
unit. But the implemented unit is not that. main.py builds the tuple as
t.name for t in next_tasks.values() if not t.writes - it is tasks with no recorded pending
write other than an error or an interrupt. After resume #1, the gate task genuinely will execute
in the next step, and .next omits
it. The rendered artifact does not merely under-specify the unit. It says something false about
this thread.
Which raises the first property, so I should meet it rather than skip it: is a field that
misdescribes itself still "correctly implemented"? I think yes, narrowly, but not for the reason I
first wrote down. .next applies one rule uniformly - tasks with no recorded writes - and the
rule is coherent. What it does not distinguish is a write that records a task's output from a
write that supplies its input, and a resume value is an input. The docstring inherits that
conflation rather than causing it. Which is, again, the subject of this article.
There is one more layer under this, and it is not the tidy one I first wrote. _loop.py defines an
internal _pending_interrupts() that subtracts resumed interrupts from pending ones and uses it to
decide whether to reject a scalar resume, and StateSnapshot.interrupts does no such subtraction.
I was about to call that the honest number the runtime keeps to itself. It is not. The subtraction
is keyed by task, so any task holding a resume write has its interrupt struck off as resolved even
when the node has gates left. In probe B's state - the article's headline case, one genuinely
pending interrupt - it returns the empty set. Per-interrupt keying could not have saved it either:
probe I showed both interrupts in a node share one id, so there is nothing to key on. It is a different
number, correct for the single decision it gates and wrong for the case this article is built on.
Which is the same defect again, in the runtime's own private counter, written for a check that only
needed to tell one from many.
Not everything qualifies, and I want to be careful about the boundary because I have been sloppy
with it once already. max_concurrency is the obvious candidate and it does not make the
category - not because its documentation is especially clear, but because its name carries its
unit. It is called concurrency, which names simultaneity. A reader who thinks max_concurrency caps total dispatched work has
ignored the word in the parameter. It fails the third property, not the second - an engineer
can certainly assume the wrong unit here, and the next paragraph is my proof of it. What keeps it
out of the category is that a rendered artifact, the parameter name, already states the unit.
recursion_limit is called a limit, reads as a total, and nothing rendered corrects that.
I know that inference is available, because I made it. My own chapter's learning objective and
summary both say "bounded the fan-out with max_concurrency" while the chapter body says the
correct thing, that it caps how many branches run at once. A 200-item fan-out at
max_concurrency=10 still dispatches 200 workers, all of which execute - which is what
Part 6 measured at that value - and so costs 200
model calls. That is the audit's second contradiction, the one I have not mentioned until now, and it
sits in the objective and the summary rather than the prose - exactly where check three below says
to look.
Beyond that, Part 8 already classified one store-API instance, BaseStore.search()'s limit=10
default. Sweeping the rest of the checkpointer and store surface I did not find a new one that
clears all three properties. recursion_limit I am not re-filing either: a Self-Restoring Bound is
the invocation-scoped instance of a Scope-Blind Guarantee, the way Part 6's Width-Blind Bound is
the width instance. They are specialisations, not synonyms.
The correction gets snapshot.next wrong too
The reconciliation document I wrote after the measurement series says the book is wrong about
snapshot.next. Its measurement line is properly scoped - ".next is () from resume #1
onward while .interrupts is still 1". Then, four lines down, the Consequence line drops the
qualifier and says a driver loop "exits early on an interrupted thread", and the Fix line tells
you to write that an interrupted thread "reports () while still holding a pending interrupt".
The measurement kept its scope. The summary of it did not.
Before publishing that, I read the source. _prepare_state_snapshot builds next like this:
for tid, k, v in saved.pending_writes: if k in (ERROR, INTERRUPT): continue if tid not in next_tasks: continue next_tasks[tid].writes.append((k, v))# ...tuple(t.name for t in next_tasks.values() if not t.writes)An INTERRUPT write is skipped, so it never lands in t.writes, so a task holding only an
interrupt has empty writes and does appear in next. The source says my correction was wrong.
Both readings were defensible and one of them was about to be published, so I ran the probe above. The result is the table you already saw: the source read is right about the first interrupt, the measurement is right about the resumed multi-interrupt case, and neither general statement is true.
Then I went back to the published article the measurement came from, and found the probe had discovered nothing. Part 7 prints its own probe output, and one of the lines reads:
invoke : effects=1 pending_interrupts=1 snap.next=('review',) snap.interrupts=1 done=NonePart 7's probes ran on 1.2.9 and mine on 1.2.6, the book's pin, and both show .next populated at
first pause. That is the single-interrupt case, with .next populated. The datum that
falsifies ".next is () on an interrupted thread" was already in print, in my own article, in a
code block, in the subsection preceding a bolded sentence that says .next is not a safe driver signal
for a thread with interrupts. I wrote the summary while looking at the output that contradicted
it.
So check three on the list below - compare the summary against the body - is not general advice I am passing along. It is the check that would have caught this, on my own article, and I did not run it.
My correction had taken a measurement of a node with two interrupts, after a resume and written it down as a rule about any paused thread. It widened the scope in the exact way the book had, one layer up, while documenting the book for widening scope.
flowchart TD
M["measured behaviour<br/>2+ interrupts in a node,<br/>after resume #1"]
B["book says:<br/>next is the resumption boundary"]
A["audit says:<br/>next is () on an interrupted thread"]
R["reader writes:<br/>while snapshot.next"]
F["exits early,<br/>reports a finished run,<br/>interrupt still pending"]
M -->|"widened to<br/>any paused thread"| B
M -->|"widened to<br/>any interrupt"| A
B --> R
A --> R
R --> F
style M fill:#6BCF7F,color:#2C2C2A
style B fill:#FFD93D,color:#2C2C2A
style A fill:#FFA07A,color:#2C2C2A
style R fill:#4A90E2,color:#FFFFFF
style F fill:#E74C3C,color:#FFFFFF
Green is what was measured. The two middle boxes are the same error at different altitudes, mine both times, three weeks apart. Restating a measurement in general terms is what writing is, and the generalisation step is unreviewable by the person who made it: to them the wider claim and the measured one feel like the same sentence. I reread that correction four times before I ran the probe.
Why LangGraph's recursion_limit does not bound total work
The recursion_limit story from the opening has a second half that makes the same shape, and this
one has a two-line proof.
self.step = self.checkpoint_metadata["step"] + 1self.stop = self.step + self.config["recursion_limit"] + 1That runs on every loop entry. stop is derived from the step recorded in the checkpoint, plus
the limit, every time. So the allowance is per invocation, not per thread. Resume a thread and it
arrives back at full. Take an ordinary second turn and it arrives back at full. The measurement
series recorded 8, 10, 10, 10 node executions across four invocations at recursion_limit=8 -
38 executions under a limit of 8.
Part 4 calls this a Self-Restoring Bound:
a bound whose remaining allowance is re-derived from
checkpointed state on every entry, rather than carried as a spent total. It guards one
invocation against a runaway loop, which is what it was built for. Raising it buys you a bigger
single invocation and nothing else. If you need a bound on total work, keep the counter in your
own state - it will not survive a crash under async durability, or a fork, but it will terminate,
which recursion_limit on a resumable thread will not.
Two caveats the docs will not give you. LANGGRAPH_DEFAULT_RECURSION_LIMIT overrides the default,
so "the default" is deployment-dependent. And passing recursion_limit=10007 explicitly is
silently dropped during config merge, because the code compares against the default to decide
whether you meant it - a sentinel-as-magic-number problem a maintainer flagged in issue #7313.
Two more LangGraph scope errors: BaseStore and the serializer
The measurement series found five things the book never mentions. Two are the same shape as everything above, and both are cheap to check in your own system.
The cross-thread store has no compare-and-swap, which
Part 8 measured in full. BaseStore.put() takes
(namespace, key, value, index) plus a keyword-only ttl. No expected_value, no version, no
etag. A concurrent read-modify-write on the same key loses a write with no exception and no second
row. I measured 2 writers leaving 1 survivor, 8 leaving 1, 32 leaving
2, with the race window deliberately widened - read those as loss rates by construction, not as
production rates. Nothing in the API prevents any of it. A request for an expected_value
parameter has sat on the LangChain forum since October 2025, with the Postgres SQL already written
in the post, and no maintainer has replied in the nine months since.
The contrast is what makes it a scope problem rather than a missing feature. In-thread, two nodes
writing the same key in one superstep with no reducer raises InvalidUpdateError, which has
its own documented error code. Cross-thread, the same conceptual mistake is silent. A reader who
learned the loud case reasonably assumes the quiet one behaves like it.
Note the qualifier. An earlier draft did not have it. Annotate that key with a reducer, which is what most real state does, and the in-thread case goes quiet too. The loud-versus-silent contrast is narrower than it first looks, and narrower in the direction that makes the store gap easier to walk into.
The default serializer does not preserve tuples. A ("8812", 4999) in state comes back as
['8812', 4999], because tuples pack as msgpack arrays and there is no extension handler for
them. Sets survive, which I measured; frozenset and deque have handlers too, which is a source
read rather than a run. The failure
mode is the worst available: within a single invocation the next node still sees a tuple, so every
in-process test passes, and it breaks on the first comparison after a resume.
I want to be careful not to overclaim from any of this. The series did find one case where a documented guarantee failed to hold - middleware resolution order, reproduced as LangChain issue #38720 - so "everything was correctly implemented" is not a claim I can make. Most of these behaviours are documented somewhere. What the sentence does not carry is its scope, and a reader does not import what the sentence does not carry.
How to audit a technical claim for scope: six checks
These are checks you can run on a paragraph in your own docs, your own runbook, or a book you paid for.
- Find the silent quantifier. Every claim about a runtime has one: any thread, any node, any invocation, this superstep. Write it in the margin. If you cannot, the claim has not been stated yet.
- Which case did the author have open? A claim written while debugging a crash will be scoped
to crashes. My
.nextsentence is correct crash-recovery advice, sitting in a paragraph a reader arrives at with a paused thread. - Check the summary against the body. Scope errors concentrate in summaries, learning objectives and callouts, where a careful sentence gets compressed into a memorable one and a skimming reader takes the rule from.
- Measure the second entry. Anything that reads state from a checkpoint behaves differently on entry two. A test suite that gives each case a fresh thread, which is correct and standard advice, is structurally incapable of exercising it. Part 7 calls that the First-Entry Assumption.
- Run a probe. Smaller than it sounds. The one that settled the
.nextquestion is a two-node graph, aninterrupt()in the first node, anInMemorySaver, and a print of.nextandlen(.interrupts)after each call. No model, no API key, no fixtures. It runs in under a second. - Record the case that produced the fix, not just the fix. My reconciliation document skipped that step, and that is how it inherited the defect.
Items 1 through 4 and 6 I ran over a morning on the .next chapter, and none of them caught
anything. Item 5 caught it in twenty minutes.
What is inside the book
Buy on Amazon: United States | India
Twenty-seven chapters across seven parts, seven appendices, and a single running system that each chapter advances by one increment. The services Atlas talks to are stubbed in the repository rather than hosted, so the whole book runs on a laptop, and the companion repository carries a tag per chapter if you want to read the code as it stood at the end of any one of them.
Parts I-II get you from a fragile while-loop to a real graph: why an agent needs a runtime, where LangGraph sits, state and reducers, conditional routing, tools and MCP, middleware. The middleware chapter is the one the panel caught.
Parts III-IV are the durability half, and the reason the book exists. Checkpointing, the refund path with its idempotency key and compensation, the approval gate a human has to pass before money moves, context budgets, and the split between short-term state and long-term memory. It is also the half that produced both scope errors in this article, which I do not think is a coincidence - it is where the number of things that can be true at different scopes goes up.
Parts V-VII run from the multi-agent decision through to governance, and end on a capstone that builds a second product entirely by reuse. That capstone runs the book's own stack-selection function, gets back "use something other than LangGraph", and then argues the override in front of you. It is the only place I know of where a book's decision procedure is allowed to rule against the book.
The book pins a tested-version matrix instead of promising "the latest". It names six concepts and stops - the glossary marks each as novel or as renamed prior art. Sixteen chapters were corrected in a revised printing after the panel found Atlas had never run end to end.
The .next comment quoted earlier is the one that shipped. Commit 048d49d corrected it in the
manuscript, along with three other scope claims the series turned up - though while writing this I
found the companion repository's atlas/run.py still carries the original wording, and a test
whose name repeats it, so that fix reached one artifact and not the other.
Part 7 now carries a dated correction pointing here: its bolded sentence was scoped too widely,
and the sentence after it - "snapshot.interrupts is" - was simply wrong, and is gone. Still
outstanding: the companion repository, the printed chapter's comment, and the reconciliation
document, all of which need the narrow scope and the probes that produced it.
The claim you cannot re-read your way out of
If you maintain documentation, a runbook, or a book, the useful question is not whether your claims are true. You have already checked that, and they probably are.
The question is what each one is quantified over, whether you wrote that down, and whether anything you ship would tell a reader who assumed something wider.
Mine did not, in two places. The document I wrote to correct the first place inherited the same defect. The one-word fix I drafted for that correction was wider still, and I only learned that because a reviewer asked what happened on a crashed thread.
Nobody has reported being bitten by any of it. I found them because I went looking.
Care produced all three of these. What broke the pattern was running the case I actually had against the version I actually ship.
References
- LangGraph.
StateSnapshotandInterrupt. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/types.py - LangGraph.
_prepare_state_snapshot. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/pregel/main.py - LangGraph.
_loop.py- step and stop derivation on loop entry. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/pregel/_loop.py - LangGraph.
DEFAULT_RECURSION_LIMITonmain(reads 10007). Source. https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/_internal/_config.py - LangGraph.
DEFAULT_RECURSION_LIMITat the1.0.6tag (reads 10000, against documentation stating 1000 for that version). Source. https://github.com/langchain-ai/langgraph/blob/1.0.6/libs/langgraph/langgraph/_internal/_config.py - LangGraph.
BaseStore.putandPutOp. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/checkpoint/langgraph/store/base/__init__.py - LangGraph.
JsonPlusSerializer. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py - LangGraph.
LastValue.update-InvalidUpdateError. Source. https://github.com/langchain-ai/langgraph/blob/main/libs/langgraph/langgraph/channels/last_value.py - LangChain. Graph API overview (recursion limit). https://docs.langchain.com/oss/python/langgraph/graph-api
- LangChain. Interrupts. https://docs.langchain.com/oss/python/langgraph/interrupts
- LangChain. INVALID_CONCURRENT_GRAPH_UPDATE. https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE
- LangChain Reference.
langgraph_sdkConfig.recursion_limit(states 25; retrieved 2026-08-14). https://reference.langchain.com/python/langgraph-sdk/schema/Config/recursion_limit - LangGraph. Do not re-execute a node that interrupted unless all of its interrupts have been resumed. Issue #6208, open. https://github.com/langchain-ai/langgraph/issues/6208
- LangGraph. Interrupts missing in thread_state on self-hosted LangGraph. Issue #6362, open. https://github.com/langchain-ai/langgraph/issues/6362
- LangChain. Middleware resolution order violates the documented last-position-wins guarantee. Issue #38720. https://github.com/langchain-ai/langchain/issues/38720
- Yurtsev, E. (2026, March 27). Update recursion limit magic number and explore changing to a sentinel value. Issue #7313. https://github.com/langchain-ai/langgraph/issues/7313
- Peilun-Li (2025, October 30). Feature Request: support concurrency safe store.put operations. LangChain Forum. https://forum.langchain.com/t/feature-request-support-concurrency-safe-store-put-operations/2014
- Kumar, R. (2026). Building Real-World Agentic AI Systems with LangGraph. https://www.amazon.com/dp/B0HBJXRMJX
Related Articles
- LangGraph or a While Loop? You Already Have a Runtime
- State Architecture for Agent Networks: The Resume Is the Dangerous Part
- LangGraph Names the Guarantee, Not the Unit
- LangGraph Evals Test the Answer, Not the Thread



