langchain-ai / langchain-ai/langgraph

Checkpoint savers disagree on list(limit=-1): InMemorySaver returns nothing, SQLite savers return everything

Open
#8,656 5 comments 0 reactions 0 assignees View on GitHub
bug external
Dominant language
Python
Stars
41.9k
Forks
7.1k
Avg merge
23h 7m
Merged PRs (30d)
30

Description

### Checked other resources

- [x] This is a bug, not a usage question.
- [x] I added a clear and descriptive title that summarizes this issue.
- [x] I used the GitHub search to find a similar question and didn't find it.
- [x] I am sure that this is a bug in LangGraph rather than my code.
- [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).
- [x] This is not related to the langchain-community package.
- [x] I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

### Related Issues / PRs

```
#7308 — Add test coverage for `before` and `limit` in `InMemorySaver` `list`/`alist`
```

### Reproduction Steps / Example Code (Python)

```python
import asyncio

from langgraph.checkpoint.base import create_checkpoint, empty_checkpoint
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver

READ = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
LIMITS = (None, 0, 2, -1)

def seed_sync(saver):
cfg = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
ckpt = empty_checkpoint()
for i in range(5):
cfg = saver.put(cfg, ckpt, {"step": i}, {})
ckpt = create_checkpoint(ckpt, None, i)

async def seed_async(saver):
cfg = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
ckpt = empty_checkpoint()
for i in range(5):
cfg = await saver.aput(cfg, ckpt, {"step": i}, {})
ckpt = create_checkpoint(ckpt, None, i)

async def main():
mem = InMemorySaver()
await seed_async(mem)
print("InMemorySaver ", {l: len([c async for c in mem.alist(READ, limit=l)]) for l in LIMITS})

async with AsyncSqliteSaver.from_conn_string(":memory:") as s:
await seed_async(s)
print("AsyncSqliteSaver ", {l: len([c async for c in s.alist(READ, limit=l)]) for l in LIMITS})

with SqliteSaver.from_conn_string(":memory:") as s:
seed_sync(s)
print("SqliteSaver ", {l: len(list(s.list(READ, limit=l))) for l in LIMITS})

asyncio.run(main())

`

Output:

`

InMemorySaver {None: 5, 0: 0, 2: 2, -1: 0}
AsyncSqliteSaver {None: 5, 0: 0, 2: 2, -1: 5}
SqliteSaver {None: 5, 0: 0, 2: 2, -1: 5}

`
```

### Error Message and Stack Trace (if applicable)

```shell

```

### Description

```
Five checkpoints are written to one thread, then listed back with several
values of `limit`. All three savers agree on `None`, `0` and `2`. They
disagree on a negative limit: `InMemorySaver` returns no checkpoints, while
both SQLite savers return all five.

Neither result is obviously wrong on its own — the problem is that they are
opposite, and nothing reports it. A caller that computes a limit arithmetically
and lands on a negative value gets "no history" on the in-memory saver and the
complete history on SQLite.

The two implementations take different paths:

`libs/checkpoint/langgraph/checkpoint/memory/__init__.py`

if limit is not None and limit <= 0:
break
elif limit is not None:
limit -= 1

The guard is `<= 0`, so any negative limit stops iteration before the first
result.

`libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py` (and the sync
saver alongside it)

query += " LIMIT ?"

The value is passed to SQLite unchanged, and SQLite documents `LIMIT` with a
negative argument as meaning no upper bound. So the limit is silently dropped
and every row comes back.

The conformance suite has a `test_list` spec, and #7308 already notes that
`limit` coverage on `InMemorySaver` is thin, so this looks like an untested
edge rather than an intentional difference.

Three behaviours seem defensible and I do not want to assume which one you
want:

1. Raise `ValueError` for `limit < 0`, rejecting it at the interface.
2. Treat any negative value as no limit, matching SQLite.
3. Treat any negative value as zero, matching `InMemorySaver`.

Whichever you prefer, a conformance spec case would keep the savers aligned.
I have the fix and regression tests ready locally for whichever option you
choose — happy to submit, please assign this to me if the approach looks right.
```

### System Info

```
langgraph-checkpoint 4.2.0
langgraph-checkpoint-sqlite 3.1.1
Python 3.13.2
OS Windows 11 (26200)

Also reproduced against langgraph main at 1e44bda4.
```

Contributor guide

Open the contributing guide

Research direction

Start with the limit handling in libs/checkpoint/langgraph/checkpoint/memory/__init__.py and the LIMIT query construction in libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py and its synchronous counterpart. Read the conformance suite's test_list spec and related issue #7308 before choosing the intended negative-limit behavior. Done means the savers agree and the behavior is covered by a conformance regression case.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, sqlite
Domain
backend, databases
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.