Home / Articles / Inside LangGraph's InMemorySaver: How Checkpoints, Writes and Blobs Fit

This article is published in English.

Inside LangGraph's InMemorySaver: How Checkpoints, Writes and Blobs Fit

Walk through the storage, writes and blobs dictionaries inside LangGraph's InMemorySaver and trace how one tiny graph run turns into three linked checkpoints.

1834 words

LangGraph's InMemorySaver is usually a one-line setup detail: you pass it to compile(), conversations suddenly remember their state, and nobody looks further. Yet the way it lays out data explains a lot about LangGraph itself, including how resuming, time travel and fault tolerance work, and why persistent checkpointers look the way they do. By tracing a minimal graph run through the saver's internal dictionaries, you will be able to read a checkpoint dump and know exactly what each entry means.

Why graphs need checkpoints

A checkpointer acts as short-term memory for a graph: it captures a snapshot of the graph's state as execution proceeds. Think of save points in a story-mode game: without them, wanting to replay level two means replaying level one first. A save records the player's progress so you can resume from that moment, even after finishing the game. LangGraph does the same after each step, so a thread can resume or replay from an earlier point.

A minimal graph to inspect

The example below builds the smallest useful graph: a typed state with name and address, a single deterministic node that sets both fields through a Command, and edges START, then get_address, then END. It compiles the graph with an InMemorySaver and an InMemoryStore, invokes it on thread "12345", and finally dumps the checkpointer's attributes. The store is a separate component for long-term data shared across threads and plays no role in what follows. Although the snippet is labeled as JavaScript, it is Python:

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from langgraph.graph import StateGraph
from typing import TypedDict, Literal
from langgraph.types import Command
from langgraph.graph.state import START, END

# we create a checkpointer, for now testing purposes we use inmemory
checkpointer = InMemorySaver()

# we will talk about this in our next blog
store = InMemoryStore()


# how you want to store your graph state which is persisted across chats
class GraphState(TypedDict):
    name: str
    address: str

# this is a determinsitic node that is present as a node
def get_address(state: GraphState) -> Command[Literal[END]]:
    return Command(update={
        "name": "pavaneeshwar",
        "address": "Hyderabad residency"
    })

# intialize graph
graph = StateGraph(GraphState)

# add this node to the graph
graph.add_node("get_address", get_address)

# by default START and END defines the START execution and end execution
graph.add_edge(START, "get_address")
graph.add_edge("get_address", END)

# the above graph we created is START => get_address => END

# we load the entire graph, this returns an object which we can run
app = graph.compile(checkpointer=checkpointer, store=store)

app.invoke({}, config={"configurable": {"thread_id": "12345"}})

# we are interested here how langgraph stores checkpointer
app.checkpointer.__dict__

The attributes of InMemorySaver

Listing the keys of the checkpointer's dictionary shows five attributes:

app.checkpointer.__dict__.keys()
# dict_keys(['serde', 'storage', 'writes', 'blobs', 'stack'])

serde: serialization and deserialization

Checkpoint data cannot be stored as live Python objects in a database, and even in memory the saver keeps it in a serialized form. serde is the serializer that converts values to and from bytes, tagging each one with a type such as msgpack.

storage: checkpoints per thread

storage holds the checkpoints themselves. Every conversation gets a thread ID, and that ID is how LangGraph retrieves the history of a specific thread. The structure is a nested dictionary: thread ID, then checkpoint namespace (an empty string for the top-level graph; subgraphs get their own namespaces), then checkpoint ID:

{
    "thread_id": {
         "namespace" : {
            "checkpoint_uuid_0": (msgpack, <binary_data>),
            "checkpoint_uuid_1": (msgpack,<binary_data>, checkpoint_uuid_0),
            "checkpoint_uuid_2": (msgpack,<binary_data>, checkpoint_uuid_1),
         }
    }
}

Each entry holds the serialized checkpoint, its serialized metadata and the ID of the parent checkpoint. That parent pointer turns the checkpoints of a thread into a linked history, which is what makes rewinding and forking possible.

writes: pending writes per checkpoint

writes records the individual updates that tasks produce. Rather than overwriting state in place, each update is recorded as a new entry keyed by thread, namespace and the checkpoint the task ran from. Inside, every write is identified by a task ID and an index:

{
    ('thread_id', 'namespace', 'checkpoint_uuid_1') : {
        ('operation_uuid_1', 0) : ('operation_uuid_1', 'channel_name', ('msgpack', '<binary data>')),
        ('operation_uuid_2', 1) : ('operation_uuid_2', 'channel_name', ('msgpack', '<binary data>'))
    }
}

The channel_name in this sketch is a placeholder. When a node updates name, the channel is name; when it updates address, the channel is address. A node that updates both at once produces two entries under the same checkpoint. Because writes are stored as soon as a task finishes, a run that fails partway through a step does not need to re-execute tasks that already succeeded.

blobs: versioned channel values

blobs stores the actual value of each channel at each version. The key combines thread, namespace, channel and version, so a checkpoint can refer to a channel value by version instead of embedding a copy:

{
    ('thread_id', 'namespace', 'channel_name', 'version') : ('mssgpack', '<binary data>')
}

stack: context management

The stack attribute is sometimes described as a queue of pending work, but in the saver's implementation it is a context-manager stack (an ExitStack) used to manage resources when the saver is entered and exited as a context manager. It does not hold graph execution state. These are private internals, so verify them against your installed version.

Tracing the run step by step

One invocation of the graph produces three checkpoints.

Checkpoint 1: the input arrives

The first checkpoint, with ID 1f1b054e-b2a5-660a-bfff-7484776ebce0, contains two msgpack payloads: the checkpoint and its metadata.

// First Message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.435773+00:00",
  "id": "1f1b054e-b2a5-660a-bfff-7484776ebce0",
  "channel_versions": {
    "__start__": "00000000000000000000000000000001.0.267464090313665"
  },
  "versions_seen": {
    "__input__": {}
  },
  "updated_channels": [
    "__start__"
  ]
}

// Second Message Pack, this is just meta data

{
  "source": "input",
  "step": -1,
  "parents": {}
}

Only the __start__ channel exists at this point. It has received its first version, updated_channels lists it, and the metadata marks the source as input with step set to -1, meaning this is the state before any graph step ran. The version strings follow a simple scheme: a zero-padded, monotonically increasing counter followed by a random fraction that keeps versions unique.

The checkpoint refers to the channel's value through its version, and the matching blob holds the data. Here the input was an empty dictionary, which msgpack encodes as the single byte \x80:

// this msgpack basically {}
('12345', '', '__start__', '00000000000000000000000000000001.0.267464090313665'): ('msgpack', b'\x80')

Checkpoint 2: routing to the node

The second checkpoint, 1f1b054e-b2a6-6294-8000-96e3a3cb81ac, records the edge from START to get_address. It is about routing, not yet about running the node:

// first message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.436094+00:00",
  "id": "1f1b054e-b2a6-6294-8000-96e3a3cb81ac",
  "channel_versions": {
    "__start__": "00000000000000000000000000000002.0.27282425125643517",
    "branch:to:get_address": "00000000000000000000000000000002.0.27282425125643517"
  },
  "versions_seen": {
    "__input__": {},
    "__start__": {
      "__start__": "00000000000000000000000000000001.0.267464090313665"
    }
  },
  "updated_channels": [
    "branch:to:get_address"
  ]
}

// second message pack
{
  "source": "loop",
  "step": 0,
  "parents": {}
}

Two channels now carry version 2. __start__ moves to a new version because its input has been consumed, and a new channel, branch:to:get_address, signals that get_address should run next. versions_seen shows that the __start__ task has seen version 1 of the __start__ channel; this bookkeeping is how LangGraph decides which nodes still need to run. The metadata switches to source loop with step 0.

The write that triggered this transition is stored under the previous checkpoint's ID, because it was produced by the task that ran from that checkpoint:

('12345', '', '1f1b054e-b2a5-660a-bfff-7484776ebce0'): {
        ('4efa087d-283c-eb5c-478a-97c592eb3802', 0): ('4efa087d-283c-eb5c-478a-97c592eb3802', 'branch:to:get_address', ('null', b''), '~__pregel_pull, __start__')
 }

Two new blobs are created as well. The __start__ blob is tagged empty, reflecting that the channel was cleared after being consumed, and the branch channel stores a null value because it only acts as a trigger:

// one created for progressing start
('12345', '', '__start__', '00000000000000000000000000000002.0.27282425125643517'): ('empty', b''),

// one for creating branch
('12345', '', 'branch:to:get_address', '00000000000000000000000000000002.0.27282425125643517'): ('null', b'')

Checkpoint 3: the node updates state

The third checkpoint, 1f1b054e-b2a6-6d66-8001-d006da4d6d19, captures the execution of get_address and its updates to name and address:

// first message pack
{
  "v": 4,
  "ts": "2026-09-14T15:56:59.436372+00:00",
  "id": "1f1b054e-b2a6-6d66-8001-d006da4d6d19",
  "channel_versions": {
    "__start__": "00000000000000000000000000000002.0.27282425125643517",
    "branch:to:get_address": "00000000000000000000000000000003.0.07103778333502464",
    "name": "00000000000000000000000000000003.0.07103778333502464",
    "address": "00000000000000000000000000000003.0.07103778333502464"
  },
  "versions_seen": {
    "__input__": {},
    "__start__": {
      "__start__": "00000000000000000000000000000001.0.267464090313665"
    },
    "get_address": {
      "branch:to:get_address": "00000000000000000000000000000002.0.27282425125643517"
    }
  },
  "updated_channels": [
    "address",
    "name"
  ]
}

// second message pack
{
  "source": "loop",
  "step": 1,
  "parents": {}
}

channel_versions always holds the latest version of every channel, while versions_seen records what each node had seen when it ran. __start__ stays at version 2 because nothing touches it again. The branch channel and the two state channels move to version 3, updated_channels lists address and name, and the step counter reaches 1.

The node wrote two values, so two writes appear under the second checkpoint's ID, one per channel, sharing the same task ID:

('12345', '', '1f1b054e-b2a6-6294-8000-96e3a3cb81ac'): {
        ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 0): ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 'name', ('msgpack', b'\xacpavaneeshwar'), '~__pregel_pull, get_address'),
        ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 1): ('a6b6f3e8-32e4-88a4-559d-cd6d409c7910', 'address', ('msgpack', b'\xb3Hyderabad residency'), '~__pregel_pull, get_address')
}

Finally, new blobs hold the msgpack-encoded strings for the two state fields:

('12345', '', 'name', '00000000000000000000000000000003.0.07103778333502464'): ('msgpack', b'\xacpavaneeshwar'),
('12345', '', 'address', '00000000000000000000000000000003.0.07103778333502464'): ('msgpack', b'\xb3Hyderabad residency')

Why the layout is designed this way

Three dictionaries for a one-node graph look like overkill, but each piece earns its place:

  • Parent-linked checkpoints give every thread a full history. You can inspect any past state, resume from it or fork a new branch from it.
  • Versioned blobs store each channel value once per change, so checkpoints stay small even when state is large and mostly unchanged.
  • Pending writes make steps resumable. If one task in a step fails, the successful tasks' writes are already saved and do not need to run again.

Persistent checkpointers such as the Postgres one keep checkpoints, blobs and writes in separate tables that mirror these dictionaries, so the same mental model applies to your database.

Key takeaways

  • InMemorySaver is meant for development and tests; its data disappears when the process exits.
  • storage holds checkpoints and metadata per thread and namespace, linked by parent IDs.
  • writes holds per-task updates keyed by the checkpoint they were produced from.
  • blobs holds channel values by version, so unchanged channels are never copied.