ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 4 of 6 30 min

Checkpointing with MongoDB

Persist conversations and paused approvals in MongoDB with a checkpointer and thread IDs, pause and resume runs with interrupt and Command, and handle the privacy and retention duties that come with stored state.

Words in this lessonCheckpointer · Thread ID · MongoDB · Docker · Interrupt · Human-in-the-loop
Checkpointer
The component that saves a snapshot of a graph's state after every step so a run can be inspected or resumed later. Like the auto-save in a video game, written to a memory card that any console can read.
Thread ID
The name that groups all the saved steps of one conversation or run, so it can be picked up again later. Like a save slot with your name on it, separate from your sister's slot on the same console.
MongoDB
A popular database that stores flexible, JSON-like documents instead of rows in fixed tables. Like a filing cabinet of folders where each folder can hold whatever papers that customer needs.
Docker
A tool that packages software with everything it needs so it runs the same way on any machine. Like shipping a sealed, ready-to-plug-in kitchen instead of a recipe and hoping the other kitchen matches.
Interrupt
A LangGraph call that pauses a run in the middle and waits, for as long as needed, for a person to respond. Like a cashier pressing the "call manager" button and leaving the sale open on the till until the manager arrives.
Human-in-the-loop
A design where people review or make the decisions an AI system should not make alone. Like an autopilot that flies the cruise while the pilot handles take-off, landing and anything unusual.
On this page
  1. Arjun's first fix, and why it failed twice
  2. Save slots and auto-save
  3. Checkpoints in memory first
  4. Moving the save files to MongoDB
  5. Following one customer through a restart
  6. Pausing overnight: approvals that survive restarts
  7. Where it breaks
  8. Try it yourself
  9. Common mistakes
  10. Recap
  11. Go deeper

At 6:04 pm on Friday, Arjun deployed a small fix to the WhatsApp ordering line. The deploy restarted both servers, one after the other, as it always did. It took ninety seconds.

By 6:30, Meera had three screenshots. The clearest one read: “I ALREADY told you. 1 kg chocolate truffle, Saturday, Kothrud. Why are you asking again?” The bot had greeted her with “Hi! What would you like to order today?” in the middle of her order. Arjun counted the logs later: thirty-seven conversations had been mid-order when the servers restarted. Every one of them started over.

There was a second casualty nobody noticed until Saturday morning. Refunds above ₹500 pause for the operations manager’s approval, and she approves them in a batch at 9 am. The four refunds waiting overnight had simply vanished. The paused runs had lived in the server’s memory, and the memory was gone.

“It’s the same bug twice,” Priya said. “Your graph remembers things in exactly one place: the RAM of whichever process is running it. Restart the process, and the conversation never happened. Today we move that memory somewhere that outlives the process.”

Important

The idea in one sentence: a checkpointer saves a snapshot of your graph’s state after every step under a thread ID, and storing those snapshots in a database like MongoDB lets any server, at any time, continue a conversation or a paused approval exactly where it stopped, even after restarts.

Arjun’s first fix, and why it failed twice

Arjun’s first idea took him ten minutes. Keep a dictionary of conversations keyed by phone number, and pass the stored messages back in each time:

CONVERSATIONS: dict[str, list] = {}   # phone -> messages

def handle(phone: str, text: str) -> str:
    history = CONVERSATIONS.get(phone, [])
    result = graph.invoke({"messages": history + [("user", text)]})
    CONVERSATIONS[phone] = result["messages"]
    return result["messages"][-1].content

It worked on his laptop. On the real setup it failed immediately, for a reason he had forgotten: Crumb runs two servers behind a load balancer (a traffic cop that sends each incoming request to whichever server is free). The customer’s first message went to server A, which stored it in A’s dictionary. Her second went to server B, whose dictionary was empty. And of course a restart still wiped both.

His second idea was to write the dictionary to a JSON file on disk. That survives a restart, but each server has its own disk, so A and B still disagree. Worse, it only saved messages. The paused refund approvals were not messages: they were runs stopped halfway through the graph, waiting at a particular node. A list of messages cannot say “this run is parked at manager_approval, with amount set to 1450”.

Save slots and auto-save

“Have you played a long video game on a console?” Priya asked.

“Too many.”

“What happens if the power cuts?”

“You lose whatever you did since the last save. The game auto-saves after each level, so it’s never much.”

“Where is the save stored?”

“On the memory card. Or the cloud now, so I can continue on my friend’s console.”

“And if your sister plays too?”

“She has her own save slot.”

Priya wrote three words on the board. Checkpoint: the auto-save, taken after every step of the graph, holding the full state and the position in the graph. Thread: the save slot, one per conversation, named by a thread ID. Checkpointer: the memory card, the place the saves live. Put the memory card in the cloud (a shared database) and any console (any server) can pick up any player’s game.

Where the analogy breaks: a game keeps only the latest save per slot. A LangGraph checkpointer keeps every checkpoint on the thread, which is what made the time travel in the last lesson possible. That is useful, and it also means storage grows with every message, which matters later.

Checkpoints in memory first

Before adding a database, see the behaviour with the in-memory checkpointer. Here is a minimal ordering assistant:

# order_line.py
from typing import Annotated, TypedDict

from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages

load_dotenv()
llm = ChatAnthropic(model="claude-opus-5-5", max_tokens=2000)
SYSTEM = ("You take cake orders for Crumb & Co. on WhatsApp. Collect four details: "
          "item, size, date, delivery area. Ask for one missing detail at a time. "
          "When you have all four, read the order back and ask them to confirm.")

class OrderChat(TypedDict):
    messages: Annotated[list, add_messages]

def assistant(state: OrderChat) -> dict:
    return {"messages": [llm.invoke([SystemMessage(SYSTEM), *state["messages"]])]}

builder = StateGraph(OrderChat)
builder.add_node("assistant", assistant)
builder.add_edge(START, "assistant")
builder.add_edge("assistant", END)

Now compile it with a checkpointer and talk to it on one thread:

# try_memory.py
from langgraph.checkpoint.memory import InMemorySaver
from order_line import builder

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "demo-1"}}

for text in ["Hi! I want a chocolate truffle cake for Saturday", "1 kg", "Kothrud please"]:
    result = graph.invoke({"messages": [("user", text)]}, config)
    print("Crumb:", result["messages"][-1].content)
Crumb: Lovely choice! What size would you like: 500 g, 1 kg or 2 kg?
Crumb: 1 kg it is. Which area should we deliver to?
Crumb: Here's your order: 1 kg chocolate truffle cake, Saturday, delivered to Kothrud. Shall I confirm it?

Look at what each invoke passes in: only the new message. Yet the third reply knows the item, the size and the date.

Note

Pause and think. The third invoke passed in a state with one message, "Kothrud please". Where did the other four messages come from, and why were they not replaced by this one-item list?

Show the answer

Two features working together. The checkpointer saw thread_id: "demo-1" in the config, loaded the latest checkpoint on that thread (four messages), and used it as the starting state. Then the add_messages reducer merged the input into it by appending, giving five messages. Without the reducer, the input would have replaced the history. Without the checkpointer, there would have been no history to append to. Arjun’s dictionary did the checkpointer’s job by hand, badly.

Now run try_memory.py a second time, but change the loop to send only "Kothrud please". The bot has no idea what you are talking about. InMemorySaver lives inside the Python process; when the process ends, so do the checkpoints. That is exactly what happened on Friday at 6:04 pm. It is perfect for tests and notebooks, and wrong for production.

Moving the save files to MongoDB

MongoDB is a popular document database: instead of tables with fixed columns, it stores flexible JSON-like documents in collections. LangGraph has an official checkpointer for it, so you can use it without designing any tables yourself.

Start MongoDB locally with Docker (a tool that runs software in isolated containers, so you do not have to install MongoDB on your machine):

docker run -d -p 27017:27017 --name crumb-mongo mongo:7
uv add langgraph-checkpoint-mongodb

-d runs it in the background, -p 27017:27017 makes MongoDB’s standard port reachable from your machine, and --name crumb-mongo gives the container a name you can refer to later. Add the connection string to .env:

MONGODB_URI=mongodb://localhost:27017

Now a tiny command-line chat that starts a fresh process for every message, which is the harshest possible test:

# chat.py
import os
import sys

from dotenv import load_dotenv
from langgraph.checkpoint.mongodb import MongoDBSaver
from order_line import builder

load_dotenv()
MONGODB_URI = os.environ["MONGODB_URI"]
DB_NAME = "crumb"

def main(thread_id: str, text: str) -> None:
    with MongoDBSaver.from_conn_string(MONGODB_URI, DB_NAME) as checkpointer:
        graph = builder.compile(checkpointer=checkpointer)
        config = {"configurable": {"thread_id": thread_id}}
        result = graph.invoke({"messages": [("user", text)]}, config)
        print("Crumb:", result["messages"][-1].content)
        print(f"({len(result['messages'])} messages on thread {thread_id})")

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

The with block opens a connection to MongoDB and closes it cleanly at the end. In a long-running web server you would open it once at startup and keep it for the life of the process.

Following one customer through a restart

Here is Friday’s customer, replayed. Each command is a separate process, so nothing survives in memory between them.

uv run chat.py wa-7f3a9c "Hi! I want a chocolate truffle cake for Saturday"
uv run chat.py wa-7f3a9c "1 kg"
docker restart crumb-mongo      # be extra harsh: restart the database too
uv run chat.py wa-7f3a9c "Kothrud please"
Crumb: Lovely choice! What size would you like: 500 g, 1 kg or 2 kg?
(2 messages on thread wa-7f3a9c)
Crumb: 1 kg it is. Which area should we deliver to?
(4 messages on thread wa-7f3a9c)
Crumb: Here's your order: 1 kg chocolate truffle cake, Saturday, delivered to Kothrud. Shall I confirm it?
(6 messages on thread wa-7f3a9c)

Trace what happened inside the third process, step by step:

  1. MongoDBSaver connects to the crumb database.
  2. invoke sees thread_id: "wa-7f3a9c" and asks the checkpointer for the latest checkpoint on that thread. MongoDB returns it: four messages, and the graph position “finished, nothing next”.
  3. The input ("user", "Kothrud please") is appended by add_messages. Five messages. A checkpoint is saved.
  4. assistant runs, the model sees all five messages and writes the read-back. Six messages. Another checkpoint is saved.
  5. invoke returns.

You can peek at the raw data with the MongoDB shell that ships inside the container:

docker exec -it crumb-mongo mongosh crumb --eval "db.getCollectionNames()"
docker exec -it crumb-mongo mongosh crumb --eval 'db.checkpoints.countDocuments({thread_id: "wa-7f3a9c"})'

In the version we used, the package creates two collections, checkpoints and checkpoint_writes, and this thread has around nine checkpoint documents: roughly three per message (the input arriving, the run starting, and the assistant finishing). These are the package’s internals, so the names may differ in yours; you should never need to query them directly. Use graph.get_state(config) and graph.get_state_history(config) from the last lesson instead. They now read from MongoDB, which means you can open a real customer’s thread from yesterday and debug it.

Now the two-server problem. Point both servers at the same MONGODB_URI. Whichever one receives a message loads the thread from MongoDB, runs one step, and saves it back. Neither server holds anything important in memory:

sequenceDiagram
    participant C as Customer
    participant A as Server A
    participant B as Server B
    participant M as MongoDB
    C->>A: "1 kg"
    A->>M: load thread wa-7f3a9c, save new checkpoints
    Note over A: deploy restarts Server A
    C->>B: "Kothrud please"
    B->>M: load thread wa-7f3a9c
    B->>C: reads back the full order

Pausing overnight: approvals that survive restarts

Now the refunds. The checkpointer makes something new possible: a graph can stop in the middle, wait hours for a person, and continue on a different server. This is called human-in-the-loop, and LangGraph’s tool for it is interrupt.

Take the refund triage graph from the first lesson of this module and put an approval step after escalate:

# refunds.py
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from triage import (TriageState, extract, auto_refund, escalate, ask_for_id,
                    reply, route_refund, REPLIES)

REPLIES["declined"] = ("We've reviewed {order_id} and can't refund it, but we'd love "
                       "to make it right. A teammate will call you today.")

def manager_approval(state: TriageState) -> dict:
    # Everything above this line runs AGAIN when the graph resumes. Keep it pure.
    answer = interrupt({
        "order_id": state["order_id"],
        "amount": state["amount"],
        "question": "Approve this refund? Reply yes or no.",
    })
    return {"decision": "refunded" if answer == "yes" else "declined",
            "log": [f"manager_approval: {answer}"]}

refund_builder = StateGraph(TriageState)
for name, fn in [("extract", extract), ("auto_refund", auto_refund),
                 ("escalate", escalate), ("ask_for_id", ask_for_id),
                 ("manager_approval", manager_approval), ("reply", reply)]:
    refund_builder.add_node(name, fn)
refund_builder.add_edge(START, "extract")
refund_builder.add_conditional_edges("extract", route_refund,
    {"small": "auto_refund", "large": "escalate", "missing": "ask_for_id"})
refund_builder.add_edge("escalate", "manager_approval")
for node in ["auto_refund", "ask_for_id", "manager_approval"]:
    refund_builder.add_edge(node, "reply")
refund_builder.add_edge("reply", END)

When manager_approval calls interrupt(...), LangGraph saves a checkpoint and stops the run. invoke returns immediately, and the result contains a special "__interrupt__" key holding the value you passed.

Friday evening, the customer’s message arrives:

# evening.py
import os
from dotenv import load_dotenv
from langgraph.checkpoint.mongodb import MongoDBSaver
from refunds import refund_builder

load_dotenv()
with MongoDBSaver.from_conn_string(os.environ["MONGODB_URI"], "crumb") as cp:
    graph = refund_builder.compile(checkpointer=cp)
    config = {"configurable": {"thread_id": "refund-CR-2207"}}
    result = graph.invoke({"message": "CR-2207 arrived squashed, refund please",
                           "log": []}, config)
    if "__interrupt__" in result:
        print("Paused for approval:", result["__interrupt__"][0].value)
    print("Next node:", graph.get_state(config).next)
Paused for approval: {'order_id': 'CR-2207', 'amount': 1450, 'question': 'Approve this refund? Reply yes or no.'}
Next node: ('manager_approval',)

The process exits. Overnight, deploy as often as you like.

Saturday, 9 am, on whichever server handles the approval dashboard, the manager clicks “yes”:

# approve.py
import os, sys
from dotenv import load_dotenv
from langgraph.checkpoint.mongodb import MongoDBSaver
from langgraph.types import Command
from refunds import refund_builder

load_dotenv()
thread_id, answer = sys.argv[1], sys.argv[2]          # e.g. refund-CR-2207 yes
with MongoDBSaver.from_conn_string(os.environ["MONGODB_URI"], "crumb") as cp:
    graph = refund_builder.compile(checkpointer=cp)
    result = graph.invoke(Command(resume=answer),
                          {"configurable": {"thread_id": thread_id}})
    print(result["reply"])
    print(result["log"])
Sorry about that! We've refunded ₹1450 for CR-2207. It reaches your account in 3-5 days.
['extract: found CR-2207 (₹1450)', 'escalate: ₹1450 is above ₹500', 'manager_approval: yes', 'reply: sent']

Command(resume="yes") tells LangGraph: load the thread, go back into the node that was interrupted, and make interrupt(...) return "yes" this time. The log shows the whole journey across two processes and a night’s sleep, as one continuous run.

Note

Pause and think. Arjun’s first version of manager_approval started with send_whatsapp(phone, "Your refund is being reviewed by a manager."), then called interrupt. Customers got that message twice. Why?

Show the answer

On resume, LangGraph does not continue from the exact line of the interrupt call. It re-runs the interrupted node from the top, and this time interrupt(...) returns the resume value instead of pausing. So everything above the interrupt ran once on Friday and again on Saturday. The fix is to put side effects somewhere that runs once: in the escalate node before it, in a node after approval, or guarded so they are idempotent (safe to repeat, like “send unless already sent”). Keep the code before an interrupt free of side effects.

To build the manager’s dashboard, keep your own small list of pending approval thread IDs (written in escalate), and for each one show graph.get_state(config).values so she sees the order, amount and customer message before deciding.

Where it breaks

Checkpoints are personal data. Every checkpoint stores the full state, which for the ordering line means every message: names, phone numbers, home addresses, sometimes health details like allergies. Treat the checkpoint database like your customer database.

Warning

Do not use raw phone numbers as thread IDs. They end up in logs, URLs and dashboards. Derive the ID with a keyed hash instead, for example "wa-" + hashlib.sha256((THREAD_SALT + phone).encode()).hexdigest()[:16] with THREAD_SALT read from the environment. This hides the ID, not the content: the messages inside still contain personal data, so also set a retention period and delete old threads.

Storage grows forever unless you clean up. Every message adds several checkpoints, and each holds a copy of the state. A busy WhatsApp line with long conversations adds up quickly. Decide a retention period (for example, 30 days after the last message), record each thread’s last activity in a collection of your own, and run a nightly job that deletes expired threads. Recent versions of the checkpointer offer a delete_thread(thread_id) method; check your version.

Two messages at once on one thread. If a customer sends “1 kg” and “chocolate” a second apart and they land on different servers, both servers load the same checkpoint and both write, and one reply may ignore the other message. Process one message at a time per thread, for example with a queue keyed by thread ID.

Async servers need the async saver. If your web server uses async code (FastAPI often does), use the async variant, AsyncMongoDBSaver, so database calls do not block other customers. Check the package page for its import path.

A checkpointer is not long-term memory. It remembers one thread. If the same customer starts a new conversation next month (a new thread), the bot will not remember that she always orders eggless cakes. Memory that spans conversations is a different tool, covered in Module 15.

Changing the graph breaks old threads. If you rename a node that a paused thread is waiting at, that thread cannot resume. Drain pending approvals before renaming nodes, or keep old names around for a release.

Library APIs change quickly. If a name has moved, check the docs linked below.

Try it yourself

  1. Run evening.py for two orders: CR-2207 (₹1,450) and a new order you add to ORDERS, say CR-2231 at ₹900, on threads refund-CR-2207 and refund-CR-2231.
  2. Restart your terminal (or the whole machine, if you like drama).
  3. Approve the first with uv run approve.py refund-CR-2207 yes and decline the second with no.
Show the expected result

Both runs pause with Next node: ('manager_approval',). After the restart, the first resumes with the refund reply and a log ending in manager_approval: yes, reply: sent. The second resumes with the “declined” reply. If you get an error that there is nothing to resume, check that the thread ID is spelled exactly the same in both scripts, and that both point to the same database name. A typo in a thread ID silently starts a new, empty thread.

Common mistakes

  • Shipping InMemorySaver to production. Every restart and every second server loses conversations. Use a database-backed checkpointer.
  • Forgetting the thread ID. With a checkpointer compiled in, invoking without {"configurable": {"thread_id": ...}} fails; invoking with a new ID starts from scratch.
  • Side effects before interrupt. The node re-runs from the top on resume. Move sends, payments and writes elsewhere.
  • Raw phone numbers as thread IDs, no retention. Hash IDs, set a retention period, delete old threads.
  • Treating checkpoints as user memory. They are per conversation. Cross-conversation memory is a separate layer.

Recap

  • A checkpointer saves the graph’s full state and position after every step; a thread ID names the conversation those checkpoints belong to.
  • InMemorySaver is for tests. MongoDBSaver.from_conn_string(uri, db_name) stores checkpoints in MongoDB, so restarts and multiple servers no longer lose anything.
  • Each invoke with the same thread ID loads the latest checkpoint and continues; add_messages appends the new input.
  • interrupt(...) pauses a run indefinitely; invoke(Command(resume=...), config) continues it, even on another server days later. The interrupted node re-runs from its top.
  • Checkpoints hold personal data: hash thread IDs, set retention, and clean up.

On Monday, Arjun ran a test in front of Meera. He sent half a cake order from his phone, restarted both servers, and sent the rest. The bot read back the full order without a blink. Then he showed her four refunds that had paused on Friday night and been approved by the operations manager over breakfast.

“So nothing lives on the servers any more?” she asked.

“Nothing that matters. The servers are like the counter staff on shift. The conversation is written in a shared ledger, and whoever’s on shift picks it up.”

Meera nodded slowly, then frowned. “Kavita has been asking me for weeks for a bot that answers questions from the operations handbook. She doesn’t want to wait for your team. Can she build one herself?”

Priya laughed. “Funny you ask. There’s a tool for exactly that.”

Go deeper

Check your understanding

3 quick questions
1. Crumb runs two servers behind a load balancer. Why does storing conversations in a Python dictionary fail even before any restart?
2. A node sends a WhatsApp message and then calls interrupt(). After the manager resumes the run, the customer has received that message twice. Why?
3. Which statement about checkpointers is true?