Skip to content

Use Postgres

Popoto's agent memory runs on PostgreSQL as well as on Redis and Valkey. It is the same model API: decay, confidence, keyword and vector search, the association graph and SubconsciousMemory's per-turn loop. On Postgres each model is a typed table with native indexes. This page takes you from an empty database to a working memory loop.

The full reference is the Postgres backend page. It covers every supported field, what the schema looks like, the outage contract and the documented divergences from Redis.

What you need

  • PostgreSQL 18 or newer. Popoto checks server_version_num on first use and refuses an older server with BackendCapabilityError.
  • A UTF8 database. A database with any other server_encoding is refused the same way. createdb uses the cluster's default encoding, so name it to be sure:

    createdb --encoding=UTF8 --template=template0 agents
    
  • The pgvector extension, if any model has an EmbeddingField. Popoto never creates or drops an extension, so a role that is allowed to must run this once per database:

    CREATE EXTENSION IF NOT EXISTS vector;
    

    The extension's schema must be on the connection's search_path. The default, public, is. The pgvector/pgvector:pg18 Docker image ships the extension. DefaultMemory has no EmbeddingField, so the loop below runs without it, but installing it up front means adding embeddings later needs no database change.

  • Python 3.10 or newer.

Redis is not needed. A process whose models are all bound to Postgres sends no Redis commands.

Install

pip install 'popoto[postgres]'

The postgres extra adds psycopg[binary,pool], psycopg-pool, pgvector, numpy (for EmbeddingField) and greenlet (for native async). import popoto never imports any of them. They load on the first use of a Postgres-bound model.

Configure

Two variables are enough. Popoto reads its own variables and never a generic one such as DATABASE_URL. The schema name, the maintenance DSN for PgBouncer, the role grants and the rest are listed in Configuration.

export POPOTO_BACKEND=postgres
export POPOTO_POSTGRES_URL=postgresql://app@db.internal:5432/agents

Selecting Postgres without POPOTO_POSTGRES_URL, or without the extra installed, raises BackendUnavailableError naming what is missing. You can also configure the backend in code with popoto.backends.set_backend(PostgresBackend(dsn=..., schema=...)).

The memory loop on Postgres

With POPOTO_BACKEND=postgres set, the default model goes to Postgres with no code change. This is the quickstart's Level 0, with a stand-in for the LLM call so it runs as is:

from popoto.recipes import SubconsciousMemory


def call_your_llm(messages):
    # Stand-in for your model call.
    return "Rollbacks re-point the load balancer at the previous colour."


sm = SubconsciousMemory(agent_id="agent-1")
sm.extract_memories(
    "Deploys use a blue-green strategy behind the load balancer.", importance=0.8
)

messages = [{"role": "user", "content": "What is our deploy strategy?"}]
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

print([m.content for m in assembly.records])

The first call creates the tables in the popoto schema: default_memory, the side tables of its keyword index and association graph, and popoto's own registry, popoto_schema. To check where the records went:

from popoto.backends import get_backend
from popoto.recipes import DefaultMemory

print(get_backend(DefaultMemory))   # <PostgresBackend schema='popoto'>
print(DefaultMemory.query.filter(agent_id="agent-1").count())

agent_id partitions every index on Postgres as it does on Redis, so many agents can share one database.

Your own model on Postgres

To put one model on Postgres while the rest of the process stays on Redis, declare Meta.backend on it. This is the README's model, bound to Postgres:

from popoto import (
    Model, AutoKeyField, KeyField, StringField, FloatField,
    DecayingSortedField, ConfidenceField, BM25Field,
)
from popoto.recipes import SubconsciousMemory


class Memory(Model):
    memory_id = AutoKeyField()
    agent_id = KeyField()
    content = StringField(default="")
    importance = FloatField(default=1.0)
    relevance = DecayingSortedField(
        base_score_field="importance",
        partition_by="agent_id",
    )
    confidence = ConfidenceField(initial_confidence=0.5)
    content_bm25 = BM25Field(source="content")

    class Meta:
        backend = "postgres"


sm = SubconsciousMemory(
    model_class=Memory,
    agent_id="agent-1",
    score_weights={"relevance": 0.6, "confidence": 0.3},
)
sm.extract_memories("The staging database is rebuilt every Monday.", importance=0.7)
messages, assembly = sm.inject_context(
    [{"role": "user", "content": "When is the staging database rebuilt?"}]
)
print([m.content for m in assembly.records])

Meta.backend beats POPOTO_BACKEND. Declaring the class never touches the network. Popoto checks the fields against the backend's capability table at declaration and refuses a field Postgres cannot store with BackendCapabilityError, so a model never fails halfway through. The connection, the version and encoding checks and the DDL all run on the model's first query or save.

Add an EmbeddingField and configure an embedding provider, and SubconsciousMemory switches to hybrid (BM25 plus pgvector) retrieval. See Embeddings.

Async

On a Postgres-bound model every async_* method runs natively on psycopg.AsyncConnection on the running event loop:

import asyncio

import popoto
from popoto.backends.postgres.aio import get_async_backend


class Note(popoto.Model):
    owner = popoto.KeyField()
    slug = popoto.KeyField()
    body = popoto.StringField(default="")

    class Meta:
        backend = "postgres"


async def main():
    await Note.async_create(owner="a", slug="1", body="first")
    async with get_async_backend(Note).transaction() as uow:
        await Note(owner="a", slug="2", body="second").async_save(pipeline=uow)
        await Note(owner="a", slug="3", body="third").async_save(pipeline=uow)
    rows = await Note.query.async_filter(owner="a")
    print(sorted(n.slug for n in rows))


asyncio.run(main())

The transaction() commits both saves or, on an exception, neither. See Async for popoto.batch() from coroutines and the rules for mixing sync and async calls.

When the database is down

An unreachable server, or one that refuses the connection, raises BackendUnavailableError (from popoto.backends). The message carries the server's own reason, for example FATAL: database "agents" does not exist, and never the DSN's password. A DSN that does not parse (an unencoded % in a URL password, an unquoted space in a keyword value) is refused as invalid connection string before any connection is attempted, without echoing its text. get_backend(Model).health counts consecutive failures and dropped writes. See the outage contract.

Harness integration

The popoto-memory hook, the MCP server and popoto-memory doctor run on Postgres too, with no Redis at all. See Harness integration.

Not on Postgres

DataFrameField is refused on Postgres. Store the frame's JSON in a DictField or a BytesField. The full list of behavioural differences is in Documented divergences.

Moving existing memory from Redis

Redis to Postgres Migration is a one-off tool that copies a Redis snapshot (an RDB file) into the Postgres backend, with a verification report. It is installed as a console script:

popoto-migrate-redis-to-postgres --help