Tag: Msgpack

  • Resolving Pydantic State Deserialization Failures in LangGraph Checkpointers

    Resolving Pydantic State Deserialization Failures in LangGraph Checkpointers

    TL;DR

    • Pydantic state deserialization problems in LangGraph checkpointers come from four distinct mechanisms with different symptoms: msgpack allowlist restrictions under strict mode, classes that cannot be re-imported (dynamic or local scope), schema drift between old checkpoints and newer models, and non-serializable attributes that actually fail when the checkpoint is written.
    • The usual symptom is silent degradation, not a raised exception: JsonPlusSerializer returns the raw kwargs dictionary (or a validation-skipped model_construct object) when it cannot rehydrate a model, so failures often surface later as an AttributeError or KeyError inside a node rather than at load time.
    • Fixes are cause-specific: register models in the msgpack allowlist for allowlist blocks, move models to module top level for import failures, add field defaults plus a model_validator or reducer for schema drift, and keep runtime objects out of state fields for the save-time case.

    Understanding LangGraph State Serialization with JsonPlusSerializer

    In LangGraph, state persistence across execution supersteps is managed by checkpointer implementations extending BaseCheckpointSaver, such as MemorySaver, SqliteSaver, and PostgresSaver. These checkpointers delegate data encoding and decoding to serialization protocols located in langgraph.checkpoint.serde. The default serialization engine is JsonPlusSerializer (langgraph.checkpoint.serde.jsonplus.JsonPlusSerializer), which encodes state primarily through msgpack, with a JSON path as fallback, while preserving standard Python types, LangChain and LangGraph primitives, and Pydantic BaseModel instances.

    When a checkpoint is loaded, the serializer rebuilds each custom object from a stored (module, class_name, kwargs) triple. Success depends on two independent conditions: whether the module passes the deserialization allowlist, and whether the class can actually be imported and constructed. By default—when LANGGRAPH_STRICT_MSGPACK is unset—the msgpack policy is permissive (allowed_msgpack_modules=True): unregistered modules are still reconstructed and only a warning is logged. Strict mode, enabled by LANGGRAPH_STRICT_MSGPACK=true or by passing an explicit allowlist, blocks modules that are not listed. The important detail is that blocking does not raise—the serializer returns the raw data instead.

    Common Causes of Deserialization Failures

    Pydantic state deserialization issues within JsonPlusSerializer stem from four distinct mechanisms. They produce different symptoms and require different fixes, so they are worth separating:

    • msgpack Allowlist Restrictions (strict mode only): Under strict mode, JsonPlusSerializer checks each custom object’s module path before instantiating it. If a Pydantic model’s module is not on the allowlist, the msgpack Pydantic branch returns the stored kwargs dictionary unchanged—no exception, and no visible signal unless you are watching the warning log. The node then receives a dict where it expected a model. InvalidModuleError is a real type, but it is raised only on the separate JSON constructor path, and even there JsonPlusSerializer‘s reviver catches it, logs a warning, and falls back to the generic LangChain reviver—so it is not the symptom to code against.
    • Classes That Cannot Be Re-Imported: Rebuilding a class during deserialization requires resolving module.ClassName back to an importable symbol. Models defined inside a function body or closure, or parametrized generic models with no concrete top-level subclass, expose no such symbol. The lookup fails and the value deserializes as a raw dictionary—independent of allowlist configuration or strict mode. Adding the class to the allowlist does not help, because the problem is resolution, not permission.
    • Schema Drift Between Checkpoint and Model: When a state model gains a required field without a default, renames a field, or tightens a type, reading an older checkpoint does not surface a Pydantic ValidationError from the serializer. It tries cls(**kwargs), and on failure falls back to cls.model_construct(**kwargs), which skips validation and returns a partially populated model; if that also fails, it returns the raw dict. A ValidationError appears only later, when a reducer or node re-validates the value.
    • Non-Serializable Attributes (a save-time failure): Placing runtime artifacts that cannot be encoded—raw exception objects, thread locks, open client sessions, file handles—inside Pydantic BaseModel fields makes the checkpoint fail when it is written, not when it is read. Despite the “deserialization” label, the fix belongs on the encoding side: keep these objects out of persisted state.

    Resolution Strategies and Best Practices

    Each strategy targets a specific mechanism above rather than all of them at once.

    • Register Models in the msgpack Allowlist (cause 1): When running with strict msgpack mode, pass custom Pydantic models to allowed_msgpack_modules on JsonPlusSerializer—as class objects or (module, qualname) tuples—or call serde.with_msgpack_allowlist([...]) to obtain a new serializer with a merged allowlist. allowed_msgpack_modules and allowed_json_modules gate two different decode paths; msgpack carries the large majority of state, so it is the one that usually matters. Compiling a StateGraph with strict mode enabled already derives an allowlist from the state, input, output, and context schemas, so—when the chosen checkpointer supports with_allowlist(...)—top-level state models are covered without manual registration:
    from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
    from langgraph.checkpoint.sqlite import SqliteSaver
    from my_app.schemas import CustomStateModel
    
    serde = JsonPlusSerializer(
        allowed_msgpack_modules=[CustomStateModel]  # or [("my_app.schemas", "CustomStateModel")]
    )
    checkpointer = SqliteSaver(conn, serde=serde)
    
    • Declare State Models at Top-Level Module Scope (cause 2): Define every Pydantic state schema, sub-model, and dataclass at the top level of an importable module rather than inside a nested function or closure. For generic models in Pydantic v2, subclass BaseModel together with typing.Generic[T] and declare an explicit concrete subclass at module scope, so the stored metadata maps to an importable symbol.
    • Design for Schema Resilience (cause 3): Give every new field a default via Field(default=...) or Field(default_factory=...) so old checkpoints still satisfy the current model. Then use @model_validator(mode="before") or a graph channel reducer to coerce any dictionary fallback payload back into the typed BaseModel before nodes observe it.
    • Keep Runtime Objects Out of State (cause 4): Persist identifiers or serializable configuration instead of live handles, and rebuild the runtime object inside the node. For genuinely custom encoding, implement SerializerProtocol (dumps_typed(obj) -> tuple[str, bytes] and loads_typed(data) -> Any) and pass it as the checkpointer’s serde.

    Sources: github.com, langchain.com, langchain.com, langchain.com, langchain.com

    Closing thoughts

    These four failure modes do not share a single root cause, and it is a mistake to treat them all as the price of security hardening. Only the msgpack allowlist check is a security control. Import-resolution failures are a packaging and code-organization problem; schema drift is a data-contract problem between a stored checkpoint and a newer model; non-serializable attributes are an encoding constraint that shows up before anything is ever read back. What the four have in common is only the outcome: JsonPlusSerializer prefers to hand back a raw dictionary or a validation-skipped object rather than raise, so a broken assumption travels silently into your graph.

    The practical consequence is that resilient graphs treat state models as version-stable data contracts and verify what actually comes out of the checkpointer. Define models at module top level so they can be re-imported, give every new field a default so old checkpoints still load, and add a model_validator or channel reducer that turns any dict fallback back into a typed model before a node touches it. Reserve allowlist configuration for the one case it addresses—strict msgpack mode—and enable strict mode deliberately rather than by accident.

    Frequently Asked Questions

    What are the primary causes of Pydantic state deserialization failures in LangGraph?

    Four distinct mechanisms: (1) under strict msgpack mode, a model whose module is not on the allowlist is returned as a raw dict; (2) classes defined in a function body, closure, or as an unsubclassed generic cannot be re-imported and also come back as dicts; (3) schema drift between an old checkpoint and a newer model, where the serializer falls back to model_construct or a raw dict rather than raising; and (4) non-serializable attributes such as locks, sessions, or exception objects in state fields, which fail when the checkpoint is written.

    What does a deserialization failure actually look like at runtime?

    Usually not an exception. JsonPlusSerializer returns the stored kwargs dictionary, or a model_construct object with validation skipped, when it cannot rehydrate a model. The failure typically surfaces later as an AttributeError or KeyError inside a node that expected a typed model. InvalidModuleError exists but is confined to the JSON constructor path and is caught internally; a ValidationError appears only if a reducer or node re-validates the value.

    How do you register custom Pydantic models for the msgpack allowlist?

    Pass them to allowed_msgpack_modules on JsonPlusSerializer, as class objects or (module, qualname) tuples, or call serde.with_msgpack_allowlist([...]) to get a new serializer with a merged allowlist. When you compile a StateGraph with strict mode enabled, LangGraph already derives an allowlist from the state, input, output, and context schemas, so top-level state models are covered without manual registration—provided the chosen checkpointer supports with_allowlist(...).

    Why must state models be defined at top-level module scope?

    Deserialization resolves a stored module.ClassName back to an importable symbol. Models defined inside nested functions, or generics without a concrete top-level subclass, have no such symbol, so they deserialize as raw dictionaries no matter how the allowlist is configured.

    How do you keep old checkpoints loading after a schema change?

    Add defaults with Field(default=...) or Field(default_factory=...) for every new field, and add a @model_validator(mode="before") or a channel reducer that coerces a plain-dict fallback back into the typed model before nodes run.