Popoto: Agent Memory on Postgres, Redis, and Valkey¶
Memory for LLM agents, as primitives you program rather than a service you call. Records decay over time, confidence moves with evidence, associations form between things mentioned together, and a context assembler packs the result into a token budget before each turn.
It runs in your process against a database you already operate. Underneath is one model API with two storage backends: Redis/Valkey, the default, and Postgres, opt-in since 1.10. You choose the backend per model or per process, and the model code does not change. Your memory data stays in your database.
Three packages, 9.0 MB of site-packages in a clean Python 3.12 venv (redis-py 8.1.0, measured 2026-09-04), no API key.
Point it at Redis or Valkey on localhost:6379 and you are running.
Choosing a backend¶
For agent memory, Postgres is the recommended substrate. Every agent-memory feature runs on it, its deployment model is one central database shared by every agent and machine, and new capabilities land there first. Redis and Valkey remain fully supported, for agent memory and for the rest of the ORM, and nothing changes for an existing Redis deployment.
pip install 'popoto[postgres]' # adds psycopg, pgvector, greenlet
export POPOTO_BACKEND=postgres # or per model: class Meta: backend = "postgres"
export POPOTO_POSTGRES_URL=postgresql://localhost:5432/agents
Postgres needs PostgreSQL 18 or newer; EmbeddingField also needs the
pgvector extension. Popoto never reads DATABASE_URL, and it creates its
tables on first use. The two backends differ in a few documented places, and
DataFrameField stays Redis-only: see the
Use Postgres walkthrough,
Postgres Backend and its
documented divergences.
An existing Redis memory store moves across with a one-off copy from an RDB
snapshot: Redis to Postgres Migration.
Memory around an LLM turn¶
from popoto.recipes import SubconsciousMemory
sm = SubconsciousMemory(agent_id="agent-1")
messages, assembly = sm.inject_context(messages) # pre-turn: retrieve + inject
answer = call_your_llm(messages) # your LLM call
sm.extract_memories(answer, importance=0.6) # post-turn: save what was learned
sm.report_outcomes(assembly, outcome="acted") # feedback: reinforce what was used
agent_id is the only required argument. It partitions every index, and both
an explicit .filter(agent_id=...) query and inject_context's default
retrieval path (lexical/BM25) honor that partition — the latter only since
1.9.0 (#576; before
that, two agents sharing one Redis via the default loop could retrieve each
other's memories). Leaving model_class
unset selects DefaultMemory, which ships the benchmarked configuration: decay,
confidence, a keyword index that makes retrieval respond to the query text, and
an association graph. The loop is the same on either backend; with
POPOTO_BACKEND=postgres set, DefaultMemory is a Postgres table.
Add memory to your agent walks the same loop up level by level, from a single decaying field to the full assembly. Running inside Claude Code, Codex, Hermes, or OpenClaw instead of your own loop? Add memory to your harness wires the same primitives into hooks and MCP, no glue code required. It runs on Redis, Valkey or Postgres.
What is measured¶
Every number here comes from a harness in this repository, with its result JSON committed alongside, and was measured on the Redis backend (Postgres numbers are in the Postgres Backend reference). Method, per-category tables, and the runs that came out badly are in Benchmarks.
- Retrieval quality. LongMemEval-S, all 500 questions, hybrid BM25 + vector: Recall@1 0.892, Recall@5 0.986, MRR 0.931. Read the granularity disclosure before comparing this to another system: Popoto ranks turns and scores hits at the session level.
- Retrieval latency. p50 3.0 ms at 1,000 records, 6.0 ms at 20,000, in-process on the lexical path, on one Apple-silicon machine. Absolute milliseconds are machine-dependent; the shape of the curve is the durable part.
- Install weight. Three packages and no API key, verified in a clean venv. Nothing here calls out to a hosted service.
End-to-end judged answer accuracy trails retrieval quality by a wide margin. Finding the right evidence is far more reliable than answering from it, and the number, its interval, and its protocol are published in Benchmarks.
Built on a full ORM for Redis, Valkey, and Postgres¶
Every memory primitive is a field on an ordinary model, so the same Django-like query syntax, indexes, TTLs, relationships, and pub/sub apply to memory records and to everything else you store, on either backend.
from popoto import Model, KeyField, Field, SortedField, GeoField
class Restaurant(Model):
name = KeyField()
cuisine = Field(type=str)
rating = SortedField(type=float)
location = GeoField()
Restaurant.create(
name="Burger Palace",
cuisine="American",
rating=4.5,
location=(40.7128, -74.0060),
)
restaurant = Restaurant.query.get(name="Burger Palace")
print(f"{restaurant.name} serves {restaurant.cuisine} food.")
# => 'Burger Palace serves American food.'
On Redis, reading and writing happen at RAM speed; on Postgres, each model is a typed table with native indexes. Popoto adds async operations, multi-tenancy via KeyField namespacing, geometric distance search, timeseries for streaming data, Pandas and Xarray interoperation, pub/sub for message queues, content and embedding fields for large content storage and semantic search, and generic export/import with per-field round-trip fidelity for moving records between stores, in one format on both backends.
Start at Configuration and Models and Fields for the ORM half of the library.
Valkey is a first-class target¶
Popoto uses core Redis data types and commands only, with no Redis-module
dependency, and the suite carries explicit Valkey-safety tests asserting that
indexes stay on plain types. Point REDIS_URL at a
Valkey server and the same code runs.
REDIS_URL is optional in local development. See
Configuration for the full connection options.
Since August 2026 that claim also rests on observed CI. The test suite —
everything except the slow-marked tests, chiefly the stress suite — runs
against a real Valkey server on every pull request and every push to main, as
a separately named pytest (Valkey) check in
tests.yml,
pinned to valkey/valkey:8-alpine alongside the Redis job's redis:7-alpine.
The job asserts via INFO server that the container really is Valkey before
pytest starts, and there has been no Valkey-only failure across 60 completed
runs.
Error reporting is opt-in¶
Library-specific exceptions can be reported to the maintainers through an isolated Sentry client that never touches your own Sentry setup. It is off until you turn it on.
Install popoto[monitoring] for the sentry-sdk dependency, or skip this
entirely. Popoto works the same either way.

Popoto is named after the Māui dolphin, the world's smallest dolphin subspecies. Dolphins are fast, agile, and work in social groups. Popoto wraps Redis and Valkey in the same spirit.
For help building applications with Python and Redis, contact Tom Counsell on LinkedIn.