LangGraph MemorySaver & SqliteSaver Setup Errors — Fix Guide (2026)
Checkpointing · MemorySaver & SqliteSaver Severity: High

LangGraph MemorySaver & SqliteSaver Setup Errors

MemorySaver is perfect for tests and loses everything on restart. SqliteSaver persists but needs schema initialization. Here are the three setup errors developers hit in their first hour with checkpointing — and the exact fixes.

TL;DREvery LangGraph checkpointer needs (1) a thread_id in the runnable config on every invoke, (2) explicit schema setup for the persistent variants (SqliteSaver, PostgresSaver), and (3) a matching sync/async saver for your app (SqliteSaver for sync code, AsyncSqliteSaver for async). MemorySaver is in-process only — it evaporates on restart. If you need persistence in prod, use SQLite for single-instance or Postgres for multi-instance.

Real error messages you'll see

ValueError — missing thread_id
ValueError — missing thread_id
ValueError: Missing configuration key 'thread_id'. Every invocation of a checkpointed graph requires config={"configurable": {"thread_id": "..."}}.
  at Pregel.astream() / Pregel.invoke()
# You compiled the graph with a checkpointer but called app.invoke(state) without config.
sqlite3.OperationalError — no such table
sqlite3.OperationalError — no such table
sqlite3.OperationalError: no such table: checkpoints
  at SqliteSaver.get_tuple() / put()
# The SQLite database exists but the checkpointer schema was never initialized.
# Fix: call SqliteSaver.setup() once, or use the from_conn_string classmethod which auto-runs setup on first use in recent versions.
RuntimeError — sync saver in async event loop
RuntimeError — sync saver in async event loop
RuntimeError: SqliteSaver is a synchronous checkpointer; use AsyncSqliteSaver inside an async event loop.
  at Pregel.astream()
# You imported SqliteSaver but the graph is invoked from an async function (FastAPI, aiohttp). Use AsyncSqliteSaver instead.

Choose the right checkpointer

CheckpointerWhen to usePersistence
MemorySaverTests, notebooks, ephemeral demosNone — cleared on restart
SqliteSaverSingle-instance apps, dev, small prodFile on disk; single-writer
AsyncSqliteSaverAsync apps (FastAPI, aiohttp) on single instanceFile on disk; single-writer
PostgresSaverMulti-instance prod, high throughputPostgres server; multi-writer
AsyncPostgresSaverAsync multi-instance prodPostgres server; multi-writer
Custom (Redis, Mongo, etc.)When Postgres isn't an optionImplement BaseCheckpointSaver

Root causes (ranked by frequency)

Based on LangGraph developer reports; percentages sum to 100%.

  • 28%
    Missing thread_id in config. Compiled with a checkpointer but called app.invoke(state) without config={"configurable": {"thread_id": "x"}}. LangGraph raises immediately.
  • 19%
    SqliteSaver schema not initialized. Used the constructor directly (SqliteSaver(conn)) without calling .setup(). The DB file exists but tables don't.
  • 15%
    Sync/async saver mismatch. Imported SqliteSaver but the graph runs inside async def. Or vice versa. Raises RuntimeError.
  • 12%
    Expecting persistence from MemorySaver. App restarts; all checkpoints are gone. MemorySaver is a dict; there is no disk.
  • 9%
    SQLite locked by another process. Multiple worker processes hit the same SQLite file. sqlite3.OperationalError: database is locked. Move to Postgres for multi-writer.
  • 7%
    Checkpointer added AFTER compile. app = graph.compile() then app.checkpointer = memory. Ignored; checkpointer must be passed to compile(checkpointer=...).
  • 5%
    Wrong connection URI format. SqliteSaver.from_conn_string("data.db") works; SqliteSaver.from_conn_string("./data.db") may fail on some platforms because of path resolution. Use absolute paths in prod.
  • 5%
    Async saver context manager not entered. AsyncSqliteSaver.from_conn_string(...) returns an async context manager — must be used with async with, not directly assigned.

How to fix it

Fix #1

Pass thread_id in the config on every invocation

The one-line fix for "Missing configuration key thread_id".

A checkpointed graph groups related invocations by thread_id. The thread_id is your conversation/session identifier — every call with the same thread_id reads and writes to the same checkpoint. Skipping it isn't optional; LangGraph refuses to run without knowing which thread the invocation belongs to.

thread_id_config.pypython
import uuid
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END, MessagesState


def responder(state):
    reply = llm.invoke(state["messages"])
    return {"messages": [reply]}


graph = StateGraph(MessagesState)
graph.add_node("responder", responder)
graph.add_edge(START, "responder")
graph.add_edge("responder", END)

checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)   # <-- attached at compile


# ✅ Always pass thread_id in config
thread_id = str(uuid.uuid4())               # or a user_id, session_id, etc.
config = {"configurable": {"thread_id": thread_id}}

result = app.invoke(
    {"messages": [HumanMessage(content="Hello")]},
    config,                                  # <-- required
)

# Same thread_id continues the conversation
result = app.invoke(
    {"messages": [HumanMessage(content="What did I just say?")]},
    config,                                  # ← same config, reads prior state
)


# ❌ WRONG — missing config, raises ValueError
# result = app.invoke({"messages": [...]})


# ✅ FastAPI pattern — thread_id from URL param
from fastapi import FastAPI
api = FastAPI()

@api.post("/chat/{session_id}")
async def chat(session_id: str, message: str):
    config = {"configurable": {"thread_id": session_id}}
    return app.invoke(
        {"messages": [HumanMessage(content=message)]},
        config,
    )
Note: The thread_id can be any string, but stable identifiers matter: use the session cookie, the user ID + chat ID, or a UUID stored in a cookie. Never generate a fresh UUID inside your request handler — every call would start a new conversation.
Fix #2

Initialize SqliteSaver with the from_conn_string classmethod

Fixes "no such table" errors and handles schema setup automatically.

SqliteSaver.from_conn_string(":memory:") or SqliteSaver.from_conn_string("data.db") is a context manager that opens the connection AND runs schema setup on first use. This is the safest way to create a checkpointer in modern LangGraph — you don't have to remember to call setup().

sqlite_saver_setup.pypython
from pathlib import Path
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import StateGraph, START, END, MessagesState


# ✅ from_conn_string — auto-runs setup(), returns a context manager
DB_PATH = Path("/var/data/langgraph_checkpoints.db").as_posix()

with SqliteSaver.from_conn_string(DB_PATH) as checkpointer:
    graph = StateGraph(MessagesState)
    graph.add_node("responder", responder)
    graph.add_edge(START, "responder")
    graph.add_edge("responder", END)
    app = graph.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "user-42"}}
    result = app.invoke({"messages": [HumanMessage(content="Hi")]}, config)


# ✅ For long-lived apps (FastAPI), open once at startup and keep the conn
import sqlite3
from contextlib import asynccontextmanager
from fastapi import FastAPI

CHECKPOINT_DB = "/var/data/langgraph.db"

@asynccontextmanager
async def lifespan(app: FastAPI):
    conn = sqlite3.connect(CHECKPOINT_DB, check_same_thread=False)
    checkpointer = SqliteSaver(conn)
    checkpointer.setup()                  # explicit setup for the raw constructor
    app.state.graph = build_graph(checkpointer)
    yield
    conn.close()

api = FastAPI(lifespan=lifespan)


# ❌ WRONG — using SqliteSaver constructor directly without setup()
# conn = sqlite3.connect("data.db")
# checkpointer = SqliteSaver(conn)         # no setup() call
# app = graph.compile(checkpointer=checkpointer)
# # First invoke raises sqlite3.OperationalError: no such table


# ✅ In-memory SQLite — perfect for tests
with SqliteSaver.from_conn_string(":memory:") as checkpointer:
    app = build_graph(checkpointer)
    # ... run tests ...
    # DB is gone when the `with` block exits — no cleanup needed
Note: The context-manager form (with SqliteSaver.from_conn_string(...) as) is preferred for scripts, one-off jobs, and tests. For long-running processes, hold the connection open at startup and pass the checkpointer to a graph factory — reopening on every request is slow.
Fix #3

Use AsyncSqliteSaver inside async apps

Fixes RuntimeError: SqliteSaver is a synchronous checkpointer.

Sync savers block the event loop and LangGraph refuses to use them inside async def. The async variants (AsyncSqliteSaver, AsyncPostgresSaver) are drop-in replacements. Same API, different import, safe under async.

async_saver.pypython
# ✅ Async SQLite — for FastAPI, aiohttp, or any asyncio app
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.graph import StateGraph, START, END, MessagesState


async def responder(state):
    reply = await llm.ainvoke(state["messages"])
    return {"messages": [reply]}


# from_conn_string is an ASYNC context manager here
async def run_chat():
    async with AsyncSqliteSaver.from_conn_string("/var/data/chat.db") as checkpointer:
        graph = StateGraph(MessagesState)
        graph.add_node("responder", responder)
        graph.add_edge(START, "responder")
        graph.add_edge("responder", END)
        app = graph.compile(checkpointer=checkpointer)

        config = {"configurable": {"thread_id": "user-42"}}
        result = await app.ainvoke(
            {"messages": [HumanMessage(content="Hello")]},
            config,
        )
        return result


# ✅ FastAPI lifespan pattern for long-lived app
from contextlib import asynccontextmanager
import aiosqlite
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    conn = await aiosqlite.connect("/var/data/langgraph.db")
    checkpointer = AsyncSqliteSaver(conn)
    await checkpointer.setup()             # await, not call
    app.state.graph = build_graph(checkpointer)
    yield
    await conn.close()

api = FastAPI(lifespan=lifespan)


# ❌ Common mistake — sync saver in async handler
# from langgraph.checkpoint.sqlite import SqliteSaver
# @api.post("/chat")
# async def chat(msg: str):
#     conn = sqlite3.connect("data.db")
#     saver = SqliteSaver(conn); saver.setup()
#     app = build_graph(saver)
#     return await app.ainvoke(...)          # RuntimeError: sync checkpointer
Note: The pattern is: sync app → SqliteSaver; async app → AsyncSqliteSaver. The two are not interchangeable. If you're using app.invoke use sync; if app.ainvoke or app.astream, use async.

Prevention checklist

  • Every checkpointed graph invocation needs config={"configurable": {"thread_id": "..."}}. Make this a wrapper function in your codebase.
  • Use SqliteSaver.from_conn_string(...) instead of the raw constructor — it auto-runs schema setup.
  • Match the saver to your code style: SqliteSaver for sync apps, AsyncSqliteSaver for async.
  • Never use MemorySaver in production — it evaporates on restart.
  • SQLite is single-writer. For multiple worker processes or replicas, migrate to PostgresSaver.
  • Compile the graph with the checkpointer, not by attaching it after: graph.compile(checkpointer=saver).
  • Use absolute file paths for SQLite in production; relative paths are resolved against wherever the process was started.

Frequently asked questions

Do I need a checkpointer at all?

Only if you want (a) resumable state across invocations (chat threads, agents that pause for input), (b) time-travel debugging, or (c) human-in-the-loop interrupts. Stateless graphs — a document classifier that runs once per input — work fine without one. If you're building a chat bot, you need a checkpointer.

Can I switch from MemorySaver to SqliteSaver later?

Yes — just change the saver you pass to compile. State schema is identical across savers. But existing in-memory checkpoints don't migrate; they live only as long as the process. If you're moving to production, plan to start fresh (or write a one-off export script that reads from the old saver and writes to the new one before shutdown).

Why does SqliteSaver error with "database is locked" under load?

SQLite is a single-writer database. With one worker process it's fine; with multiple gunicorn/uvicorn workers each opening the same file, they'll contend on the write lock and one will fail. SQLite has some workarounds (WAL mode, longer busy timeout) but for multi-process production, use PostgresSaver. It's a straight swap — same API, no code change beyond the saver import and connection string.

How do I inspect checkpoints in a SQLite file?

Open the file with any SQLite client: sqlite3 langgraph.db "SELECT thread_id, checkpoint_id, ts FROM checkpoints ORDER BY ts DESC LIMIT 20". LangGraph also provides checkpointer.list(config) to enumerate checkpoints for a thread programmatically. In dev, LangSmith's trace view visualizes checkpoints inline with the run graph.

Do checkpointers store the full message history?

Yes. Every state key — including messages — is serialized into the checkpoint. This is how conversations resume. On very long threads the checkpoints can grow large; if that's a concern, add a prune node that emits RemoveMessage tombstones once history exceeds a cap. Alternatively, offload long history to external storage and keep only a reference (message IDs or a summary) in state.

Related errors