File-Based and Database-Backed Long-Term Memory Stores
Pick a store that matches concurrency, multi-user needs, and ops maturity - start with structured files for single-user tools, move to SQLite or Postgres when agents share hosts or tenants.
Search across all documentation pages
Pick a store that matches concurrency, multi-user needs, and ops maturity - start with structured files for single-user tools, move to SQLite or Postgres when agents share hosts or tenants.
Implement a thin MemoryStore interface (get, upsert, list, delete) over JSON files first, then the same interface over SQLite so application code does not care which backend holds long-term facts.
id, user_id, namespace, key, value (JSON), source, timestamps, optional expires_at.(user_id, namespace, key) for profile-style facts.import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
def utcnow() -> str:
return datetime.now(timezone.utc).isoformat()
class MemoryStore(Protocol):
def upsert(self, user_id: str, key: str, value: Any, *, namespace: str = "profile") -> None: ...
def get(self, user_id: str, key: str, *, namespace: str = "profile") -> Any | None: ...
def list(self, user_id: str, *, namespace: str | None = None) -> list[dict]: ...
def delete(self, user_id: str, key: str, *, namespace: str = "profile") -> bool: ...
class JsonFileMemoryStore:
def __init__(self, path: str | Path = "ltm.json") -> None:
self.path = Path(path)
if not self.path.exists():
self.path.write_text('{"rows":[]}', encoding="utf-8")
def _load(self) -> dict:
return json.loads(self.path.read_text(encoding="utf-8"))
def _save(self, data: dict) -> None:
self.path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def upsert(self, user_id: str, key: str, value: Any, *, namespace: str = "profile") -> None:
data = self._load()
now = utcnow()
for row in data["rows"]:
if row["user_id"] == user_id and row["namespace"] == namespace and row["key"] == key:
row["value"] = value
row["updated_at"] = now
self._save(data)
return
data["rows"].append({
"user_id": user_id,
"namespace": namespace,
"key": key,
"value": value,
"created_at": now,
"updated_at": now,
})
self._save(data)
def get(self, user_id: str, key: str, *, namespace: str = "profile") -> Any | None:
for row in self.list(user_id, namespace=namespace):
if row["key"] == key:
return row["value"]
return None
def list(self, user_id: str, *, namespace: str | None = None) -> list[dict]:
rows = [r for r in self._load()["rows"] if r["user_id"] == user_id]
if namespace is not None:
rows = [r for r in rows if r["namespace"] == namespace]
return rows
def delete(self, user_id: str, key: str, *, namespace: str = "profile") -> bool:
data = self._load()
before = len(data["rows"])
data["rows"] = [
r for r in data["rows"]
if not (r["user_id"] == user_id and r["namespace"] == namespace and r["key"] == key)
]
self._save(data)
return len(data["rows"]) < before
class SqliteMemoryStore:
def __init__(self, path: str = "ltm.db") -> None:
self.conn = sqlite3.connect(path)
self.conn.row_factory = sqlite3.Row
self.conn.execute(
"""
CREATE TABLE IF NOT EXISTS memories (
user_id TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, namespace, key)
)
"""
)
self.conn.commit()
def upsert(self, user_id: str, key: str, value: Any, *, namespace: str = "profile") -> None:
now = utcnow()
self.conn.execute(
"""
INSERT INTO memories(user_id, namespace, key, value_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, namespace, key) DO UPDATE SET
value_json=excluded.value_json,
updated_at=excluded.updated_at
""",
(user_id, namespace, key, json.dumps(value), now, now),
)
self.conn.commit()
def get(self, user_id: str, key: str, *, namespace: str = "profile") -> Any | None:
cur = self.conn.execute(
"SELECT value_json FROM memories WHERE user_id=? AND namespace=? AND key=?",
(user_id, namespace, key),
)
row = cur.fetchone()
return json.loads(row["value_json"]) if row else None
def list(self, user_id: str, *, namespace: str | None = None) -> list[dict]:
if namespace is None:
cur = self.conn.execute(
"SELECT user_id, namespace, key, value_json, created_at, updated_at FROM memories WHERE user_id=?",
(user_id,),
)
else:
cur = self.conn.execute(
"SELECT user_id, namespace, key, value_json, created_at, updated_at FROM memories WHERE user_id=? AND namespace=?",
(user_id, namespace),
)
return [
{
"user_id": r["user_id"],
"namespace": r["namespace"],
"key": r["key"],
"value": json.loads(r["value_json"]),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
for r in cur.fetchall()
]
def delete(self, user_id: str, key: str, *, namespace: str = "profile") -> bool:
cur = self.conn.execute(
"DELETE FROM memories WHERE user_id=? AND namespace=? AND key=?",
(user_id, namespace, key),
)
self.conn.commit()
return cur.rowcount > 0
# Same application code against either backend:
store: MemoryStore = SqliteMemoryStore()
store.upsert("user-42", "preferred_language", "Portuguese")
store.upsert("user-42", "stack", {"language": "python", "framework": "fastapi"}, namespace="project")
print(store.get("user-42", "preferred_language"))
print(store.list("user-42"))Notes:
namespace separates profile prefs from project facts without new tables.Add file locking or accept last-write-wins if two processes may run.
Use WAL mode for better concurrent readers; keep the DB file off ephemeral container disks without a volume.
pgvector for episodic retrieval (see vector section).| Field | Why |
|---|---|
user_id / tenant_id | Isolation |
namespace + key | Upsert surface for profile facts |
value JSON | Flexible without schema thrash |
source | Trust and conflict policy |
expires_at | Temporary memories |
version or updated_at | Stale write detection |
Episodic memories ("user mentioned migrating to Kubernetes last quarter") often need a free-text content column and optional embedding id rather than only key/value pairs.
Application agents should depend on MemoryStore, not on SQL strings scattered through prompts.
Frameworks (LangGraph checkpointers, LlamaIndex memory modules, and others - verify at build) can wrap the same table, but your product still owns the schema and delete paths.
Export/import as JSON lines so you can move environments without proprietary lock-in.
user_id. Bind identity from the auth layer on the server.| Approach | Pros | Cons |
|---|---|---|
| JSON / YAML files | Simple, debuggable | Weak concurrency |
| SQLite | Solid local default | Not multi-host by itself |
| Postgres | Production multi-tenant | Ops overhead |
| Redis | Fast, TTLs easy | Less ideal as sole durable LTM |
| Object storage blobs | Cheap archives | Poor random fact updates |
| Provider "memory" products | Fast to adopt | Portability and policy opacity |
Use framework helpers for speed, but keep a clear schema and export path you control. Product requirements outlive any one library.
Rows per key make partial updates and deletes easier. A single document is fine at small scale if you version it carefully.
Use an episodes namespace or table with append semantics (id, content, created_at) instead of overwriting a single key.
Yes with different tables or TTLs. Do not mix session scratch state into the always-loaded profile namespace.
(user_id, namespace) for list, and primary key (user_id, namespace, key) for upsert. Add full-text or vector indexes only when retrieval needs them.
Include tenant_id in the primary key and every query. Test with cross-tenant access attempts in CI.
Yes for required fields inside JSON. Validate on read and write with Pydantic models when shapes stabilize.
File store is fine for one process. SQLite costs minutes and teaches the production interface earlier.
Either a column on the episode row (pgvector) or a side index keyed by memory id. Keep the canonical text in the primary store.
Online delete is immediate; backups may retain data until expiry. Document that lag in your privacy policy and retention design.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026