PRACTICAL GUIDE / LangGraph checkpoint schema migration testing

Keep LangGraph checkpoint migrations from erasing live threads

Learn to prove old LangGraph checkpoints survive state changes, diagnose misleading failures, and roll out compatibility fixes without losing threads.

By The Testing AcademyUpdated August 4, 202625 min read
All field guides
In this guide6 sections
  1. Why a harmless state rename drops live data
  2. Build an oracle that proves the old value survives
  3. Test three migrations that fail differently
  4. Distinguish schema loss from the lookalikes
  5. Roll the change through an existing suite
  6. Know what the fix costs and when to skip it

What you will learn

  • Why a harmless state rename drops live data
  • Build an oracle that proves the old value survives
  • Test three migrations that fail differently
  • Distinguish schema loss from the lookalikes

An agent resumes a support thread after deployment and suddenly addresses the customer as None. New conversations are fine, the unit tests for the renamed field are green, and the checkpoint still exists. The release changed customer_name to display_name, but nobody proved that an old checkpoint could cross that boundary.

Why a harmless state rename drops live data

LangGraph state is not one untyped document that every graph version reads unchanged. A StateGraph defines named state channels, and a checkpointer saves snapshots of those channels for a thread. The thread_id in the runnable configuration identifies the thread. When the graph has a checkpointer, get_state returns a StateSnapshot for the latest checkpoint selected by that configuration, or for a specific checkpoint_id when one is supplied.

That model makes a schema edit part of the runtime contract. The new Python type declaration may compile, but an older checkpoint was written with the old channel names and values. According to LangGraph's graph migration guidance, adding and removing state keys is supported in both directions. A rename is different. Existing threads do not acquire the value under the new name, and an incompatible type change can cause problems when old state reaches new code.

The practical consequence is easy to miss in review. A developer sees customer_name removed and display_name added in the same diff and reads that as a rename. The runtime sees two unrelated channel operations. Removing the old channel makes its value unavailable to code that only knows the new schema. Adding the new channel creates a place for future writes, but it does not infer where its first value should come from. Similar spelling, matching type annotations, and a clean database migration do not create that mapping.

There are two schemas to keep separate during an investigation. Your graph state schema belongs to the application. The checkpointer also has an internal storage format and provider-specific tables or collections. A change to customer_name is normally an application-state compatibility problem, not permission to rewrite the checkpointer's internal rows. Direct SQL against serialized checkpoint payloads couples the migration to implementation details and can miss checkpoint metadata, channel versions, pending writes, or serializer behavior. Test through the public graph and checkpointer interfaces unless the saver provider documents a storage migration you must run.

The deployed graph version matters as much as the saved bytes. LangGraph's backward compatibility documentation says the latest graph is applied to new and existing threads rather than pinning each run to the code that started it. On resume, the runtime loads saved state and dispatches work using the current graph. A compatibility test must therefore reproduce both halves of the handoff: version one writes a real checkpoint, then version two reads or resumes the same thread through the same checkpointer.

A dictionary passed directly to a migration function covers only the conversion rule. It does not prove that the new compiled graph can see the legacy channel, that the same thread was selected, or that an interrupted run still points to a node present in the new topology. Conversely, an end-to-end test that only checks the final assistant sentence can pass after losing a field. A fallback such as state.get("display_name", "Customer") may produce fluent text while silently discarding the person's name.

Use three layers of evidence. First, inspect the checkpoint immediately before the new graph runs. The legacy value, thread ID, checkpoint ID, and pending node should match the fixture you created. Second, inspect the state immediately after the compatibility node writes the new representation. Both the business value and the migration marker should be present. Third, let a downstream node consume only the new representation. That last assertion catches a bridge that appears to migrate data but leaves production code reading the deprecated key.

An application-owned state_version field can make those rules explicit, but it is not a built-in LangGraph schema version. Give it semantics your tests can state precisely. For example, version 1 may mean that customer_name is authoritative, while version 2 means that display_name is authoritative and the old key exists only for a deprecation window. Never use the number as proof by itself. A buggy node can stamp state_version: 2 without copying the name, so the oracle must assert both the version and the converted business value.

Completed and interrupted threads also deserve separate cases. LangGraph documents broad topology changes for threads that have reached the end. An interrupted thread can still be scheduled to enter a node recorded in its snapshot. Renaming or removing that node leaves the runtime with no matching destination. A passing migration against a completed conversation says nothing about a thread parked before await_approval, which is often the thread with the most important state to preserve.

Build an oracle that proves the old value survives

Start with a red-risk reproducer, not a fixture already shaped like the new release. The test below compiles a version-one graph, writes customer_name through InMemorySaver, and then asks an unsafe version-two graph to read the same checkpoint. The shared saver and identical thread_id are essential controls. If either changes, the test no longer isolates a field rename.

This diagnostic intentionally asserts the behavior that makes a direct rename unsafe. ticket_id remains visible because both schemas retain that channel. display_name is absent because no graph node copied the old value into it. The test will also catch a future framework change in this behavior instead of leaving your suite dependent on an undocumented assumption.

Python
from typing import TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph


class StateV1(TypedDict):
    ticket_id: str
    customer_name: str


class UnsafeStateV2(TypedDict, total=False):
    ticket_id: str
    display_name: str


def compile_graph(state_schema: type, saver: InMemorySaver):
    builder = StateGraph(state_schema)

    def prepare_reply(state: dict) -> dict:
        return {"ticket_id": state["ticket_id"]}

    builder.add_node("prepare_reply", prepare_reply)
    builder.add_edge(START, "prepare_reply")
    builder.add_edge("prepare_reply", END)
    return builder.compile(checkpointer=saver)


def test_direct_rename_does_not_copy_checkpoint_value() -> None:
    saver = InMemorySaver()
    config = {"configurable": {"thread_id": "support-1842"}}

    old_graph = compile_graph(StateV1, saver)
    old_graph.invoke(
        {"ticket_id": "T-17", "customer_name": "Mira"},
        config,
    )
    assert old_graph.get_state(config).values["customer_name"] == "Mira"

    unsafe_graph = compile_graph(UnsafeStateV2, saver)
    observed = unsafe_graph.get_state(config).values

    assert observed["ticket_id"] == "T-17"
    assert "display_name" not in observed

Do not turn the final assertion into observed.get("display_name") is None if None is a valid customer-facing value. Presence and value are separate properties. A missing channel, an explicitly null value, and an empty string may require different recovery actions. State that distinction in the contract instead of letting Python truthiness choose it accidentally.

The safe rename is an add-then-remove change. During the bridge release, both names remain in the state schema. A compatibility node reads either representation, rejects disagreement, and writes the canonical value to both channels. New nodes read display_name; any old node still reached by an interrupted thread can continue reading customer_name. The second release removes the old read path only after old threads have drained or been migrated.

Here is the positive integration test. It uses the old graph to produce the checkpoint, so removing the copy inside copy_identity makes the test fail. Changing the thread ID makes it fail. Replacing the shared saver with a fresh instance makes it fail. Those are useful failure properties because each represents a real implementation mistake rather than a hard-coded fixture disagreeing with another hard-coded fixture.

Python
from typing import NotRequired, TypedDict

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph


class LegacyState(TypedDict):
    ticket_id: str
    customer_name: str


class BridgeState(TypedDict):
    ticket_id: str
    customer_name: NotRequired[str]
    display_name: NotRequired[str]
    state_version: NotRequired[int]


def build_legacy_graph(saver: InMemorySaver):
    builder = StateGraph(LegacyState)

    def prepare_reply(state: LegacyState) -> dict:
        return {"ticket_id": state["ticket_id"]}

    builder.add_node("prepare_reply", prepare_reply)
    builder.add_edge(START, "prepare_reply")
    builder.add_edge("prepare_reply", END)
    return builder.compile(checkpointer=saver)


def copy_identity(state: BridgeState) -> dict:
    old_value = state.get("customer_name")
    new_value = state.get("display_name")

    if old_value is None and new_value is None:
        raise ValueError("checkpoint contains no customer identity")
    if old_value is not None and new_value is not None and old_value != new_value:
        raise ValueError("checkpoint contains conflicting customer identities")

    canonical = new_value if new_value is not None else old_value
    return {
        "customer_name": canonical,
        "display_name": canonical,
        "state_version": 2,
    }


def build_bridge_graph(saver: InMemorySaver):
    builder = StateGraph(BridgeState)
    builder.add_node("prepare_reply", copy_identity)
    builder.add_edge(START, "prepare_reply")
    builder.add_edge("prepare_reply", END)
    return builder.compile(checkpointer=saver)


def test_bridge_copies_a_value_from_a_real_v1_checkpoint() -> None:
    saver = InMemorySaver()
    config = {"configurable": {"thread_id": "support-1842"}}

    legacy_graph = build_legacy_graph(saver)
    legacy_graph.invoke(
        {"ticket_id": "T-17", "customer_name": "Mira"},
        config,
    )

    bridge_graph = build_bridge_graph(saver)
    bridge_graph.invoke({"ticket_id": "T-17"}, config)
    migrated = bridge_graph.get_state(config).values

    assert migrated["ticket_id"] == "T-17"
    assert migrated["customer_name"] == "Mira"
    assert migrated["display_name"] == "Mira"
    assert migrated["state_version"] == 2


def test_bridge_rejects_conflicting_identity_values() -> None:
    try:
        copy_identity(
            {
                "ticket_id": "T-18",
                "customer_name": "Mira",
                "display_name": "M. Rao",
            }
        )
    except ValueError as error:
        assert str(error) == "checkpoint contains conflicting customer identities"
    else:
        raise AssertionError("conflicting identity values were accepted")

The conflict branch is deliberate. Suppose a partially rolled-out writer has already put display_name: "M. Rao" beside customer_name: "Mira". Choosing the new field merely because its version number is higher hides a data disagreement. Choosing the old one does the same in the opposite direction. A blocked thread with both values preserved is easier to repair than a successful thread whose customer identity was guessed.

This bridge is lazy. It migrates a checkpoint the next time the graph runs that node. Lazy conversion avoids rewriting every dormant checkpoint, but it also means dormant threads can stay on version 1 for the whole deprecation period. If the old field must disappear by a fixed privacy or retention date, a controlled backfill may be necessary. The same converter can be reused, but the backfill needs its own idempotency, retry, audit, and rollback tests.

Test three migrations that fail differently

A rename is only the first useful fixture. A credible compatibility suite includes changes where the expected response is not “copy the value.” Otherwise the migration layer can degenerate into a generic dictionary rename helper that approves data it does not understand.

Consider a retry counter stored as text in version 1 and as an integer in version 2. The tempting conversion is int(raw) wrapped in a broad exception handler that falls back to zero. That makes the graph continue, but it changes the meaning of a malformed checkpoint. An agent that already attempted an external action may receive a fresh retry budget. The safer contract accepts only the legacy representation you actually emitted, rejects booleans and unrecognized text, and is idempotent for values already migrated.

The following tests exercise valid legacy data, current data, and corruption. Each assertion can fail after a real implementation regression. Removing state_version, treating bool as an integer, resetting an existing count, or swallowing "three" will trip a different row.

Python
from typing import Any

import pytest


def migrate_retry_count(state: dict[str, Any]) -> dict[str, Any]:
    version = state.get("state_version", 1)
    raw_count = state.get("retry_count")

    if type(version) is not int:
        raise ValueError(f"state_version must be an integer: {version!r}")

    if version == 1:
        if (
            not isinstance(raw_count, str)
            or not raw_count.isascii()
            or not raw_count.isdecimal()
        ):
            raise ValueError("v1 retry_count must contain ASCII decimal digits")
        migrated_count = int(raw_count)
    elif version == 2:
        if type(raw_count) is not int:
            raise ValueError("v2 retry_count must be an integer")
        migrated_count = raw_count
    else:
        raise ValueError(f"unsupported state_version: {version!r}")

    return {**state, "retry_count": migrated_count, "state_version": 2}


def test_converts_the_legacy_counter_without_resetting_it() -> None:
    migrated = migrate_retry_count(
        {"state_version": 1, "retry_count": "03", "tool": "refund"}
    )
    assert migrated["retry_count"] == 3
    assert migrated["tool"] == "refund"
    assert migrated["state_version"] == 2


def test_current_state_is_idempotent() -> None:
    current = {"state_version": 2, "retry_count": 3, "tool": "refund"}
    assert migrate_retry_count(current) == current


@pytest.mark.parametrize("bad_value", [None, "three", "-1", "٣", -1, True])
def test_rejects_legacy_values_the_converter_cannot_explain(bad_value: object) -> None:
    with pytest.raises(ValueError):
        migrate_retry_count({"state_version": 1, "retry_count": bad_value})


@pytest.mark.parametrize("bad_version", [True, 1.0, "1", None, 3])
def test_rejects_unsupported_or_non_integer_versions(bad_version: object) -> None:
    with pytest.raises(ValueError):
        migrate_retry_count({"state_version": bad_version, "retry_count": "03"})

The "-1" case deserves a review with the owning engineer. The converter rejects a signed string, which is correct only if version 1 never wrote signed counts. It also rejects non-ASCII decimal characters because this example's old writer emitted ordinary ASCII digits. Do not broaden the converter because a hypothetical legacy format might exist. Search actual fixtures and production telemetry, document the set of emitted representations, and add a test before accepting another one. Migration code should be strict about history, not creative about it.

A third failure appears when the bytes are technically compatible but the meaning of the graph has changed. Imagine version 2 inserts a policy check between triage and response. An old thread may already have passed triage under version-one rules. Sending it through the new check retroactively changes the workflow. Skipping the check for every thread would let new work bypass the new policy. Both paths load correctly, so a schema-only assertion stays green.

Record the behavior version before the branch that needs it. New threads receive version 2 at intake. Existing threads without the field are interpreted as version 1. The full integration suite should cover an old checkpoint, a new checkpoint before the policy node, and a new checkpoint after the policy node. The fallback is not a generic default; it preserves the only behavior that threads without the marker could have started under.

Python
from typing import Literal, NotRequired, TypedDict


class RoutingState(TypedDict):
    request: str
    flow_version: NotRequired[int]
    policy_checked: NotRequired[bool]


def route_after_triage(
    state: RoutingState,
) -> Literal["policy_check", "respond"]:
    version = state.get("flow_version", 1)

    if type(version) is not int:
        raise ValueError(f"flow_version must be an integer: {version!r}")

    if version == 1:
        return "respond"
    if version == 2 and state.get("policy_checked") is not True:
        return "policy_check"
    if version == 2:
        return "respond"
    raise ValueError(f"unsupported flow_version: {version!r}")


def test_missing_marker_uses_the_legacy_route() -> None:
    assert route_after_triage({"request": "reset password"}) == "respond"


def test_new_thread_cannot_skip_policy_check() -> None:
    state = {"request": "reset password", "flow_version": 2}
    assert route_after_triage(state) == "policy_check"


def test_checked_new_thread_can_continue() -> None:
    state = {
        "request": "reset password",
        "flow_version": 2,
        "policy_checked": True,
    }
    assert route_after_triage(state) == "respond"


def test_boolean_is_not_accepted_as_a_flow_version() -> None:
    try:
        route_after_triage({"request": "reset password", "flow_version": True})
    except ValueError as error:
        assert str(error) == "flow_version must be an integer: True"
    else:
        raise AssertionError("a boolean flow version was accepted")

These functions are unit tests for the routing policy. They cannot prove that intake stamps version 2, that a conditional edge calls this router, or that a saved pause resumes through the expected node. Keep a LangGraph integration case that writes each relevant pause with the old graph, resumes it with the candidate graph, and asserts the resulting node and state.

This pattern has a timing constraint. Adding flow_version after triage cannot help checkpoints already waiting beyond triage unless the fallback encodes their old behavior. Stamping every missing checkpoint with version 2 during resume would be technically neat and semantically wrong. Test from the exact pause locations that existed in the old graph, not only from START.

Reducer changes need their own migration row too. A list channel with an append reducer does not behave like a scalar channel with replacement semantics. A bridge that “copies” the whole historical list on every resume may append the same entries again. Assert item identities and order after two resumes, not just after one. If duplicate entries are valid in the domain, use stable event IDs or another business key rather than set(); deduplication that destroys legitimate repeats is not a repair.

One migration suite can now detect at least four distinct defects: a renamed channel loses a value, a type converter invents a fallback, a behavior version reroutes old work, and a reducer replays an accumulation. They should not share one vague assertion such as assert result["valid"]. Each case needs a business invariant that changes when the corresponding implementation is broken.

Distinguish schema loss from the lookalikes

The first useful question is not “did migration fail?” It is “which evidence disappeared?” A renamed field usually leaves the rest of the checkpoint intact. The same thread_id still has history, unchanged channels still have their values, and the latest snapshot has a checkpoint ID. Only the new channel lacks a mapped value. If every channel is absent, investigate thread selection and persistence before touching the converter.

A wrong thread_id creates a new thread boundary. LangGraph's interrupt documentation describes the ID as the persistent cursor: reusing it selects the saved thread, while a different value starts a new one. This often happens when one service prefixes tenant IDs and another does not, or when a test fixture generates a fresh UUID for the resume call. Compare the exact configuration used for the write and the read. Do not normalize it in the assertion, because the production bug may be in that normalization.

A restarted InMemorySaver produces a similar empty read for a different reason. The saver holds data in RAM and does not persist across process restarts. A same-process test with a shared instance proves graph compatibility, but it cannot prove deployment continuity. When the failure follows a pod restart and all known thread IDs lose all history, the storage choice is the leading suspect. Use the persistent checkpointer configured for production to test that boundary.

An interrupted thread with a removed node gives different evidence again. Its values may be completely intact. The latest StateSnapshot.next tuple names the node or nodes scheduled next, and the old node name can still appear there. If version 2 removed that node, a state converter cannot fix the destination. Keep the deprecated node during the drain window, or route new threads to a separately versioned graph while old threads finish on compatible code.

Collect a compact report instead of pasting serialized checkpoint rows into a ticket. The function below uses documented graph methods and StateSnapshot fields. It records identity, the set of visible value keys, the pending destinations, and the number of checkpoints returned for that thread. It deliberately omits state values so a diagnostic artifact does not leak conversation content or credentials.

Python
import json
from typing import Any


def checkpoint_evidence(graph: Any, thread_id: str) -> dict[str, Any]:
    config = {"configurable": {"thread_id": thread_id}}
    history = list(graph.get_state_history(config))

    if not history:
        return {
            "found": False,
            "thread_id": thread_id,
            "history_depth": 0,
        }

    latest = graph.get_state(config)
    configurable = latest.config["configurable"]
    return {
        "found": True,
        "thread_id": configurable["thread_id"],
        "checkpoint_id": configurable.get("checkpoint_id"),
        "history_depth": len(history),
        "next": list(latest.next),
        "value_keys": sorted(str(key) for key in latest.values),
    }


def print_checkpoint_evidence(graph: Any, thread_id: str) -> None:
    print(json.dumps(checkpoint_evidence(graph, thread_id), sort_keys=True))

Read that report as a decision tree. found: false for the expected ID means there is no history visible through that graph and checkpointer configuration. Check the write-side ID, namespace, backend, environment, and process lifecycle. found: true with customer_name but no display_name identifies an unmigrated bridge release. found: true with next: ["await_approval"] tells you the thread is parked before that node; compare the name with the deployed topology before resuming.

Do not assert a particular checkpoint ID value. The important property is continuity: the observed snapshot belongs to the expected thread, and a new checkpoint created by migration has a valid parent relationship or appears in that thread's history. Hard-coding an ID generated during fixture setup merely tests the fixture. Likewise, do not assert an exact history length unless your graph topology and super-step boundaries are the subject of the test. LangGraph creates checkpoints at super-step boundaries, so adding a legitimate node can change the count without losing state.

Subgraphs can complicate the report because checkpoint namespaces identify parent and nested graph state separately. A field visible in a subgraph snapshot is not automatically evidence that the parent has the same value. If the bug affects nested graphs, include checkpoint_ns in the evidence and inspect the relevant graph boundary. Do not describe that as a renamed-field failure until the snapshot from the correct namespace is missing the mapped channel.

A serializer failure is another near-miss. It usually appears while saving or loading a value type, not as one cleanly missing renamed channel among otherwise valid values. Reproduce it using the same saver and serializer configuration as the failing environment. Replacing the problematic object with a string may make the test green, but it also stops testing the value that failed to serialize. Either keep checkpoint state in supported serializable types or configure a documented serializer strategy with an explicit security review.

Finally, separate product migration defects from damaged fixtures. Before blaming version two, ask version one to read the checkpoint it wrote. If the legacy graph cannot retrieve customer_name, the precondition is broken. If version one sees the value and the bridge graph sees the old value but never writes the new one, the bridge is defective. If both versions see and copy it but a downstream response is wrong, the consumer or prompt assembly owns the next investigation.

Roll the change through an existing suite

Begin the rollout by inventorying the states you actually have. Keep fixtures for completed threads, interrupted threads at each retained node boundary, old checkpoints without a version marker, already migrated checkpoints, and malformed values observed in real data. Do not fabricate dozens of arbitrary state combinations and call that coverage. A smaller set tied to reachable production histories provides better evidence.

For a self-managed deployment, LangGraph does not provide a generic search index over every thread's state. Your application or deployment layer must supply the inventory. If you already know a thread_id, get_state and get_state_history can inspect it. If you do not know the IDs, use the supported thread listing in your deployment platform or the application index that owns them. Scanning internal saver tables with an ad hoc query may miss namespaces or couple the release check to a particular saver version.

Phase one is additive. Introduce display_name as NotRequired, retain customer_name, and deploy the compatibility node. Keep old node names that appear in interrupted snapshots. New writes should populate both identity fields during the window, and new business logic should read the canonical field. Record a real migration failure when neither value exists or when both disagree. Do not convert those cases into anonymous defaults.

Phase two exercises reads from both directions. Write checkpoints with the last production version and resume them with the candidate. Also create candidate checkpoints and confirm the bridge release can read them after a rollback. That backward direction matters because a failed deployment may return traffic to code that only understands customer_name. Dual-writing both names buys rollback compatibility at the cost of extra state and more conflict cases.

Run each old fixture at the pause point it represents. A completed-thread test may start a fresh graph execution on the same thread. An interrupted fixture must resume from its saved destination, preserve any interrupt contract, and avoid duplicating side effects. LangGraph nodes can restart from the beginning when resumed around an interrupt, so external actions before the interrupt need their own idempotency protection. A checkpoint compatibility pass does not prove that payment, email, or ticket creation is safe to repeat.

Phase three observes real migration completion. Count only states you can identify through supported application or deployment interfaces. Useful evidence includes the application state version, presence of the canonical field, conflicts detected by the bridge, and interrupted threads still pointing to deprecated nodes. Do not publish an invented completion percentage. The numerator and denominator must come from the same defined population, and dormant threads excluded by retention policy should be named as excluded.

Phase four removes the old path. Wait until the agreed population has drained, been converted, expired under a documented retention rule, or been assigned a manual recovery plan. Then remove old reads before old writes, or do both only when rollback no longer requires version-one code. Retain the legacy fixtures after cleanup. They are the regression tests that stop a future refactor from skipping directly from “deprecated” to “forgotten.”

Put the compatibility suite in its own CI target so ordinary unit-test selection cannot omit it. The script below assumes the project has already installed its pinned test dependencies. Each file name states a different contract, which makes a failed job useful before anyone opens a trace.

Shell
#!/usr/bin/env bash
set -euo pipefail

python -m pytest -q \
  tests/checkpoints/test_identity_rename_bridge.py \
  tests/checkpoints/test_retry_count_conversion.py \
  tests/checkpoints/test_flow_version_routing.py \
  tests/checkpoints/test_interrupted_thread_compatibility.py

Wire that command after dependency installation, using the same supported Python and LangGraph versions as the application. A valid GitHub Actions job can stay small:

YAML
name: checkpoint compatibility

on:
  pull_request:
    paths:
      - "src/agent/**"
      - "tests/checkpoints/**"
      - "requirements*.txt"

permissions:
  contents: read

jobs:
  checkpoint-compatibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: python -m pip install -r requirements.txt -r requirements-test.txt
      - run: bash scripts/test-checkpoint-compatibility.sh

Pin dependencies in the repository rather than installing an unbounded latest release in this job. The compatibility target is comparing application versions; silently changing the framework version adds a second variable. Test framework upgrades in a separate change with the same old-checkpoint corpus, then update the pin after the behavior is understood.

Production-like verification should use an isolated instance of the actual persistent backend. Seed checkpoints through version-one application code, stop that process, start version two, and resume through the public graph API. Keep the data synthetic and the backend disposable. This test costs more setup time than InMemorySaver, but it covers serialization, durable connection configuration, and process restart, which an in-memory test cannot reach.

Avoid using production checkpoint copies unless the organization has approved the privacy and access implications. Conversation state can contain user text, tool arguments, internal identifiers, and model output. Minimal synthetic fixtures are usually enough for field renames and routing versions. When a production-only shape caused the incident, reduce it to the smallest sanitized fixture that still reproduces the failure and document which fields were removed.

Know what the fix costs and when to skip it

Dual fields increase checkpoint size and make every writer more complicated. The meaningful cost is not only storage. During the bridge window, every code path that writes identity must keep two representations consistent, and every conflict needs an owner. A long window improves compatibility for dormant threads but extends that maintenance burden and may duplicate sensitive data past the preferred retention point.

Lazy migration spreads work over normal resumes and avoids a bulk rewrite. It also leaves uncertainty about threads that never resume during the observation window. A backfill gives a bounded population and earlier conflict discovery, but it adds database load, retry handling, audit records, and rollback design. Choose between them based on thread lifetime, regulatory deadlines, and the ability to enumerate checkpoints. Do not justify a backfill merely because it makes a dashboard reach 100 percent.

Strict conversion deliberately turns ambiguous data into an operational failure. That can increase the number of threads sent to manual recovery during rollout. The alternative is worse when the field controls money, authorization, identity, tool retries, or customer-visible commitments. For low-risk display preferences, a documented default may be acceptable. Put that default in a named policy function and test the exact legacy conditions that activate it.

Keeping an old node for interrupted threads slows topology cleanup. The node may need security fixes and monitoring even when no new traffic should reach it. A separately versioned graph avoids carrying old code in the new path, but routing and ownership become more complex. Either choice needs a way to identify which graph owns a thread. A hidden fallback from missing node to a similarly named new node is not a safe compromise because the input and side-effect contract may differ.

Do not build this migration machinery when the graph has no checkpointer and every run is intentionally stateless. There is no stored thread to migrate. A normal unit test for the new state schema is enough. The same applies when the product explicitly expires all threads on deployment and users have accepted that behavior, though the expiry itself should be visible rather than masquerading as successful resume.

An optional additive field may not require a converter either. If old checkpoints can omit it, old paused nodes do not read it, and the first new node computes it from current authoritative data, test that computation and the missing-field path. Adding a fake state_version plus a no-op migration node would increase moving parts without protecting a business invariant.

Do not use an application-state bridge to upgrade the checkpointer library's own tables. Follow the saver provider's documented setup or migration process for that operation. The tests here can verify that old application checkpoints remain readable afterward, but they do not replace a storage backup, provider migration plan, or rollback rehearsal.

Long-term user facts may belong in a LangGraph store rather than thread checkpoints. Checkpointers hold thread-scoped state; stores hold application-defined data that can be shared across threads. Moving a durable preference out of a checkpoint is an architectural data migration with different identity and retention rules. Copying it into display_name on every thread can create multiple stale sources instead of one compatible state.

Some values cannot be migrated honestly. A new authorization model may require evidence the old checkpoint never captured. A redesigned approval record may bind the approver to a tool name, arguments, and expiry that version one stored only as approved: true. Do not manufacture those missing fields or carry the boolean forward as equivalent approval. Keep the old thread blocked, request approval again, or finish it on code that enforces the old contract if policy permits.

Skip automatic conversion when human review is the only way to resolve conflicting values. The bridge should surface the thread ID, checkpoint ID, field names, and a privacy-safe reason, then stop before the disputed value reaches a tool or response. That trade-off costs operator time and resume latency. It preserves the one property a migration suite is supposed to defend: old state must not become new, believable, incorrect state.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

AI Tester Blueprint

Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.

From the instructor behind this guide.

AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official docs.langchain.com reference

    docs.langchain.com

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.langchain.com reference

    docs.langchain.com

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.langchain.com reference

    docs.langchain.com

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official docs.langchain.com reference

    docs.langchain.com

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Does LangGraph automatically migrate a renamed state key?

No. Current LangGraph guidance says a renamed key loses its saved value in existing threads, so use an add-then-remove window or an explicit converter. Prove the conversion with a checkpoint written by the old graph.

Why is get_state empty after my LangGraph deployment?

First verify the exact thread_id and the configured checkpointer. A new thread ID selects a new thread, while InMemorySaver also loses everything when its process restarts. Missing every key points to identity or persistence; one missing renamed key points to schema compatibility.

Can I use InMemorySaver for checkpoint migration tests?

Use it for fast, same-process compatibility tests where two graph versions share one saver instance. It cannot prove restart durability, database serialization, or production checkpointer setup, so keep one staging test on the backend you actually deploy.

How do I test an interrupted thread before removing a node?

Inspect the latest StateSnapshot before resuming it. If snapshot.next contains the old node name, the replacement graph must retain that node until the thread drains or route new work through a versioned graph.

Should a migration silently default malformed legacy data?

Silence is usually the dangerous choice because it converts corruption into believable state. Reject values that cannot be mapped unambiguously, preserve the checkpoint for investigation, and define an explicit recovery path for that thread.