LastValue: the default overwrite channel
Responsibilities
LastValue is the simplest subclass of BaseChannel: it stores one value, and every write overwrites the old one (LastValue class:20-21). It is the default channel used when a state field has no Annotated annotation. When you write class State(TypedDict): count: int in a StateGraph, the count field seen at compile time is backed by a LastValue(int) (fallback LastValue:1857-1859).
Its key constraint is "at most one value per step". When update receives a sequence longer than 1, it neither merges nor overwrites — it raises InvalidUpdateError (update:56-67), prompting the user to use Annotated[int, reducer] to express "how multiple concurrent writes in this step should be folded". This is not a technical limitation but a semantic choice: scalar state has no reasonable merge behavior, so rather than silently dropping values it errors out explicitly.
The same file also contains a variant, LastValueAfterFinish (LastValueAfterFinish:81-152): it behaves like LastValue, but a written value only becomes visible after finish() is called at the end of the entire Pregel run, and is cleared after being read once. It is used for "interrupt signals" or "end signals" — internal channels that only take effect at the end of a run.
Design motivation
- Overwrite is the only sensible default for scalar state: integers, strings, booleans have no "merge" semantics. For a field like
count: int, the intuitive behavior when a new value arrives is to overwrite the old one, soStateGraphcompiles any field without a reducer annotation toLastValueinfallback:1857. - Refuse to silently drop values: if two nodes write
countin the same superstep,LastValuedoes not pick a winner — it raisesINVALID_CONCURRENT_GRAPH_UPDATE(InvalidUpdateError:60-64). The error message explicitly points to "Use an Annotated key to handle multiple values", steering the user toward the correct fix — adding a reducer, see /channels/topic-binop. MISSINGinstead ofNone: the initial value islanggraph._internal._typing.MISSING(value = MISSING:29), soNoneis a legitimate stored value. WhenvalueisMISSING,get()raisesEmptyChannelError(get:69-72). This mirrors theBaseChanneldefault, butis_availableis overridden as the one-linerself.value is not MISSING(is_available:74-75) to avoid a try/except on every call.copyis cheaper thanfrom_checkpoint: although the parent defaultscopy = from_checkpoint(checkpoint()), herecopyis overridden (copy:44-48) to share the samevaluereference directly — scalars do not need deep copy.from_checkpoint(from_checkpoint:50-54) branches onMISSINGto decide whether to assign.LastValueAfterFinishdeferred visibility: some control signals (such asCommand's goto) must not be visible toprepare_next_tasksat write time, otherwise they would self-trigger within the same step and form a cycle.LastValueAfterFinishuses afinishedflag (finished flag:93-95);get()only returns whenfinished=True(get gated:145-148),finish()is invoked by Pregel at close (finish:138-143), andconsume()clears the value after it has been consumed once (consume:130-136).
Key files
LastValue class:20-25— class definition, genericBaseChannel[Value, Value, Value]: stored, written, and checkpoint types are all the sameValue.__init__:27-29— initializesvalue = MISSING; only becomes non-MISSINGafter a write.ValueType / UpdateType:34-42— both returnself.typ, the type declared in the annotation.copy:44-48— reuses thevaluereference directly; scalars do not need deep copy.from_checkpoint:50-54— rebuilds an instance from a checkpoint value;MISSINGmeans start from empty.update:56-67— core constraint: empty sequence returnsFalse, length greater than 1 raisesInvalidUpdateError, otherwise stores the last value and returnsTrue.get / is_available:69-75— read interface;MISSINGraisesEmptyChannelError;is_availableis overridden as a one-line check.LastValueAfterFinish:81-95— variant: adds afinishedflag, deferring visibility until afterfinish().LastValueAfterFinish.update:122-128— resetsfinishedon write, meaning a new write revokes the previous visibility.consume / finish / get:130-148— three hooks cooperate to implement "clear after one read" semantics.fallback LastValue:1857-1859— at compile timeStateGraphfalls back toLastValue(annotation)for any field without a reducer annotation.
Data flow
The entire logic of LastValue.update is just these lines (update:56-67):
def update(self, values: Sequence[Value]) -> bool:
if len(values) == 0:
return False
if len(values) != 1:
msg = create_error_message(
message=f"At key '{self.key}': Can receive only one value per step. Use an Annotated key to handle multiple values.",
error_code=ErrorCode.INVALID_CONCURRENT_GRAPH_UPDATE,
)
raise InvalidUpdateError(msg)
self.value = values[-1]
return TrueNote that the third branch uses values[-1] rather than values[0] — because the previous check already rejected len != 1, this line only runs in the len == 1 case, where [-1] and [0] are equivalent. Returning False on an empty sequence is what lets LastValue correctly report "unchanged" when apply_writes calls update(EMPTY_SEQ) on unchanged channels (EMPTY_SEQ update:329).
How StateGraph maps an unannotated field to LastValue at compile time is in field_to_channel:1840-1859:
if manager := _is_field_managed_value(name, annotation):
if allow_managed:
return manager
else:
raise ValueError(f"This {annotation} not allowed in this position")
elif channel := _is_field_channel(annotation):
channel.key = name
return channel
elif channel := _is_field_binop(annotation):
channel.key = name
return channel
fallback: LastValue = LastValue(annotation)
fallback.key = name
return fallbackIt tries in order: managed value (special management like in MessagesState) → explicit BaseChannel instance → Annotated[..., reducer] (BinaryOperatorAggregate, see /channels/topic-binop) → if nothing matches, fall back to LastValue.
The full read/write timeline:
Boundaries and failure modes
- Multi-value concurrent writes raise:
multi-value error:59-64raisesInvalidUpdateErrorwith error codeINVALID_CONCURRENT_GRAPH_UPDATE. This is the most common pitfall for newcomers: two nodes writing the same scalar field at once. The fix is to switch to a reducer-annotated field likeAnnotated[int, lambda a, b: a + b]. - Empty write returns
False:empty seq:57-58returnsFalse, consistent with theBaseChannelcontract, soapply_writesskips thechannel_versionsbump. __eq__checks type, not value:__eq__:31-32returnsisinstance(value, LastValue), so any twoLastValueinstances are considered equal regardless of what they store. This is for deduplication at graph compile time — do not use it for value comparison.MISSINGvsNone:Noneis a legitimate stored value; onlyMISSINGmeans "never written" (get / is_available:70-75). If your business field allowsNone, write it freely — the channel will not treat it as empty.- A new write to
LastValueAfterFinishrevokes visibility:LAF.update:122-128starts withself.finished = False. So if a write arrives afterfinish(), the channel re-enters the "unpublished" state and only becomes readable after the nextfinish(). LastValueAfterFinish.consumeis a no-op whenfinishedis false: seeLAF.consume:130-136. A value that was "written but not yetfinish-ed" is not cleared; it is only cleared after it has been consumed once — exactly what its "end signal" semantics require.
Summary
LastValue is the default shape of LangGraph state — it stores one scalar, overwrites on update, and the "one value per step" constraint raises rather than silently dropping values on multi-value writes. Understanding it means understanding how every field without a reducer annotation behaves at runtime. To see how concurrent writes are correctly merged, read on at /channels/topic-binop; for the channel interface definition go back to /channels/base-channel; for when update is actually called see /pregel/algo.
See official docs: LangGraph 文档 · README.