Skip to content

Nested Enum fields become None after checkpoint deserialization, breaking Pydantic validation #6718

@sai14366

Description

@sai14366

Checked other resources

  • This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

from enum import StrEnum
from typing import TypedDict
from pydantic import BaseModel
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, END, START
from langgraph.types import interrupt, Command

# Pydantic model with nested enum (enum defined inside class)
class DatasetArtifact(BaseModel):
    class PhaseEnum(StrEnum):
        QUERY = "query_ready"
        READY = "ready"
    
    phase: PhaseEnum
    item_id: str | None = None

# Graph state
class GraphState(TypedDict):
    artifact: DatasetArtifact

# Track execution step
_execution_count = 0

# Node that interrupts (checkpoints state)
def checkpoint_node(state: GraphState):
    global _execution_count
    _execution_count += 1
    artifact = state['artifact']
    
    if _execution_count == 1:
        # First execution: before checkpoint, enum is correct
        print(f"[BEFORE CHECKPOINT] Artifact type: {type(artifact)}")
        print(f"[BEFORE CHECKPOINT] Phase type: {type(artifact.phase)}")
        print(f"[BEFORE CHECKPOINT] Phase value: {artifact.phase}")
        assert isinstance(artifact, DatasetArtifact)
        assert artifact.phase == DatasetArtifact.PhaseEnum.QUERY
    else:
        # Second execution: after resume, state loaded from checkpoint (BUG HERE!)
        print(f"Artifact type: {type(artifact)}")
        print(f"Phase type: {type(artifact.phase)}")
        print(f"Phase value: {artifact.phase}")
        # This will fail because phase is None
        assert artifact.phase == DatasetArtifact.PhaseEnum.QUERY, "Phase should be PhaseEnum.QUERY"
    
    return interrupt("Need approval")

# Node that runs after resume
def after_resume_node(state: GraphState):
    return state

# Build graph
graph = StateGraph(GraphState)
graph.add_node("checkpoint", checkpoint_node)
graph.add_node("resume", after_resume_node)
graph.add_edge(START, "checkpoint")
graph.add_edge("checkpoint", "resume")
graph.add_edge("resume", END)

# Compile with checkpointer
checkpointer = InMemorySaver()
compiled = graph.compile(checkpointer=checkpointer)

# Initial run - creates checkpoint after interrupt
print("=" * 60)
print("STEP 1: Initial run (creates checkpoint)")
print("=" * 60)
config = {"configurable": {"thread_id": "test-1"}}
initial_state = {"artifact": DatasetArtifact(phase=DatasetArtifact.PhaseEnum.QUERY, item_id="123")}
result1 = compiled.invoke(initial_state, config=config)
print("\nCheckpoint created after interrupt")

# Resume from checkpoint - state is loaded from checkpoint
print("\n" + "=" * 60)
print("STEP 2: Resume from checkpoint (loads state)")
print("=" * 60)
try:
    result2 = compiled.invoke(Command(resume={}), config=config)
except AssertionError as e:
    # Expected: assertion fails because phase is None
    print(f"\n BUG: {e}")
    print("\nFull stack trace:")
    import traceback
    traceback.print_exc()
# Expected: result2['artifact'].phase is PhaseEnum.QUERY
# Actual: result2['artifact'].phase is None

Error Message and Stack Trace (if applicable)

============================================================
STEP 1: Initial run (creates checkpoint)
============================================================
[BEFORE CHECKPOINT] Artifact type: <class '__main__.DatasetArtifact'>
[BEFORE CHECKPOINT] Phase type: <enum 'PhaseEnum'>
[BEFORE CHECKPOINT] Phase value: query_ready
[BEFORE CHECKPOINT] Is DatasetArtifact: True

Checkpoint created after interrupt

============================================================
STEP 2: Resume from checkpoint (loads state)
============================================================
Artifact type: <class '__main__.DatasetArtifact'>
Phase type: <class 'NoneType'>
Phase value: None
Is DatasetArtifact: True

 BUG: Phase should be PhaseEnum.QUERY
Traceback (most recent call last):
  File "test_nested_enum_bug.py", line 88, in <module>
    result2 = compiled.invoke(Command(resume={}), config=config)
  File ".../langgraph/pregel/main.py", line 3026, in invoke
    for chunk in self.stream(
                 ~~~~~~~~~~~^
        input,
        ^^^^^^
    ...<10 lines>...
        **kwargs,
        ^^^^^^^^^
    ):
    ^
  File ".../langgraph/pregel/main.py", line 2647, in stream
    for _ in runner.tick(
             ~~~~~~~~~~~^
        [t for t in loop.tasks.values() if not t.writes],
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ...<2 lines>...
        schedule_task=loop.accept_push,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ):
    ^
  File ".../langgraph/pregel/_runner.py", line 162, in tick
    run_with_retry(
    ~~~~~~~~~~~~~~^
        t,
        ^^
    ...<10 lines>...
        },
        ^^
    )
    ^
  File ".../langgraph/pregel/_retry.py", line 42, in run_with_retry
    return task.proc.invoke(task.input, config)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File ".../langgraph/_internal/_runnable.py", line 657, in invoke
    input = context.run(step.invoke, input, config, **kwargs)
  File ".../langgraph/_internal/_runnable.py", line 401, in invoke
    ret = self.func(*args, **kwargs)
  File "test_nested_enum_bug.py", line 54, in checkpoint_node
    assert artifact.phase == DatasetArtifact.PhaseEnum.QUERY, "Phase should be PhaseEnum.QUERY"
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Phase should be PhaseEnum.QUERY
During task with name 'checkpoint' and id 'f703daeb-106d-cf27-274c-84d7f5c1f0a3'

Description

What I'm doing:
Using LangGraph checkpointing with Pydantic models that contain nested enum fields (enums defined inside a class, like DatasetArtifact.PhaseEnum).

What I expect:
After checkpointing and resuming, the enum field should remain as the enum instance (e.g., PhaseEnum.QUERY), and the Pydantic model should remain as a model instance.

What actually happens:

  1. The enum field becomes None after checkpoint deserialization
  2. Pydantic validation fails because phase: None doesn't match phase: PhaseEnum
  3. The Pydantic model falls back to a dict instead of a model instance
  4. Code that expects a model instance gets AttributeError: 'dict' object has no attribute '...'

Root cause:
In langgraph/checkpoint/serde/jsonplus.py, the _msgpack_ext_hook() function (line ~380) handles EXT_CONSTRUCTOR_SINGLE_ARG (used for enums) by trying to import the enum class directly from the module:

# Current code
return getattr(importlib.import_module(tup[0]), tup[1])(tup[2])
# Tries: getattr(module, "PhaseEnum")("query_ready")

For nested enums (e.g., DatasetArtifact.PhaseEnum), this fails because I bieleve:

  • The enum is not at module level: module.PhaseEnum doesn't exist
  • It's nested: module.DatasetArtifact.PhaseEnum exists
  • getattr(module, "PhaseEnum") raises AttributeError
  • Exception handler returns None instead of the enum value

Impact:

  • Breaks Pydantic validation for models with nested enum fields
  • Causes models to become dicts instead of model instances
  • Leads to AttributeError when accessing model methods/attributes

Workaround:
Currently using a Pydantic @model_validator(mode="before") to fix None enums and re-instantiate models, but this shouldn't be necessary.

Additional Context

Related issues:

Suggested fix:
When enum deserialization fails in _msgpack_ext_hook(), return the enum value (string) instead of None. This allows Pydantic to validate the value and reconstruct the enum correctly.

Location: langgraph/checkpoint/serde/jsonplus.py, _msgpack_ext_hook() function, EXT_CONSTRUCTOR_SINGLE_ARG case (line ~380)

System Info

python -m langchain_core.sys_info

System Information

OS: Linux
OS Version: #91-Ubuntu SMP PREEMPT_DYNAMIC Tue Nov 18 14:14:30 UTC 2025
Python Version: 3.13.5 | packaged by Anaconda, Inc. | (main, Jun 12 2025, 16:09:02) [GCC 11.2.0]

Package Information

langchain_core: 1.1.3
langchain: 1.1.3
langsmith: 0.4.58
langchain_classic: 1.0.0
langchain_openai: 1.1.1
langchain_text_splitters: 1.0.0
langgraph_sdk: 0.2.15

Optional packages not installed

langserve

Other Dependencies

async-timeout: 5.0.1
httpx: 0.28.1
jsonpatch: 1.33
langgraph: 1.0.4
openai: 2.9.0
opentelemetry-api: 1.39.0
opentelemetry-exporter-otlp-proto-http: 1.39.0
opentelemetry-sdk: 1.39.0
orjson: 3.11.5
packaging: 25.0
pydantic: 2.12.5
pytest: 9.0.2
pyyaml: 6.0.3
requests: 2.32.5
requests-toolbelt: 1.0.0
rich: 14.2.0
sqlalchemy: 2.0.45
tenacity: 9.1.2
tiktoken: 0.12.0
typing-extensions: 4.15.0
uuid-utils: 0.12.0
zstandard: 0.25.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpendingawaiting review/confirmation by maintainer

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions