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

Open
opened 2026-02-20 17:43:10 -05:00 by yindo · 1 comment
Owner

Originally created by @sai14366 on GitHub (Jan 23, 2026).

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:

  • Issue #6598 mentions enum serialization but focuses on StrEnum conversion, not nested enums
  • This is specifically about nested enums (enums defined inside classes) becoming None

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

Originally created by @sai14366 on GitHub (Jan 23, 2026). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/). - [x] I added a clear and detailed title that summarizes the issue. - [x] I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). - [x] 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 ```python 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) ```shell ============================================================ 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: ```python # 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:** - Issue #6598 mentions enum serialization but focuses on StrEnum conversion, not nested enums - This is specifically about nested enums (enums defined inside classes) becoming None **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
yindo added the bugpending labels 2026-02-20 17:43:10 -05:00
Author
Owner

@Aparnap2 commented on GitHub (Jan 27, 2026):

Hi , I've analyzed this issue and would like to work on it if it's still available.

Analysis

The issue stems from jsonplus.py in libs/checkpoint. currently using obj.__class__.__name__ during serialization for Enum (and other types). For nested classes like DatasetArtifact.PhaseEnum, __name__ returns just "PhaseEnum", but the class is not available at the module level.

When _msgpack_ext_hook attempts to deserialize via getattr(importlib.import_module(tup[0]), tup[1]), it fails to find PhaseEnum in the module, raises an AttributeError, and hits the except Exception: return block, resulting in None.

Proposed Fix

I propose a two-part fix in libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py:

  1. Serialization: Update _msgpack_default to use __qualname__ instead of __name__. This will store "DatasetArtifact.PhaseEnum" instead of "PhaseEnum", preserving the nesting information.
  2. Deserialization: Update _msgpack_ext_hook to handle dot-separated paths in the class name. Instead of a single getattr, it will perform a nested lookup (e.g., reduce(getattr, name.split("."), module)).
  3. Fallback Safety: As a safety net, if class resolution fails (e.g. code moved), we should return the raw value (the enum value) instead of None. Pydantic models can often validate/reconstruct from the raw value, whereas None guarantees a validation error.

I can also add a regression test in libs/checkpoint/tests ensuring nested Enums survive a round-trip.

Let me know if this approach aligns with your constraints!

@Aparnap2 commented on GitHub (Jan 27, 2026): Hi , I've analyzed this issue and would like to work on it if it's still available. ### Analysis The issue stems from `jsonplus.py` in `libs/checkpoint`. currently using `obj.__class__.__name__` during serialization for `Enum` (and other types). For nested classes like `DatasetArtifact.PhaseEnum`, `__name__` returns just `"PhaseEnum"`, but the class is not available at the module level. When `_msgpack_ext_hook` attempts to deserialize via `getattr(importlib.import_module(tup[0]), tup[1])`, it fails to find `PhaseEnum` in the module, raises an AttributeError, and hits the `except Exception: return` block, resulting in `None`. ### Proposed Fix I propose a two-part fix in `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py`: 1. **Serialization**: Update `_msgpack_default` to use `__qualname__` instead of `__name__`. This will store `"DatasetArtifact.PhaseEnum"` instead of `"PhaseEnum"`, preserving the nesting information. 2. **Deserialization**: Update `_msgpack_ext_hook` to handle dot-separated paths in the class name. Instead of a single `getattr`, it will perform a nested lookup (e.g., `reduce(getattr, name.split("."), module)`). 3. **Fallback Safety**: As a safety net, if class resolution fails (e.g. code moved), we should return the raw `value` (the enum value) instead of `None`. Pydantic models can often validate/reconstruct from the raw value, whereas `None` guarantees a validation error. I can also add a regression test in `libs/checkpoint/tests` ensuring nested Enums survive a round-trip. Let me know if this approach aligns with your constraints!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#1126