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.
Summary
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.
Recipe
- Define a stable schema:
id,user_id,namespace,key,value(JSON),source, timestamps, optionalexpires_at. - Implement upsert on
(user_id, namespace, key)for profile-style facts. - Add list/query helpers filtered by user (and tenant when multi-tenant).
- Keep writes on the server; never let the model choose another user's id.
- Graduate from file → SQLite → managed Postgres as concurrency and ops require.
- Separate audit logs (append-only transcripts) from online memory (curated rows).
- Plan backup, migration, and delete-by-user before launch.
Working Example
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:
namespaceseparates profile prefs from project facts without new tables.- Values stay JSON so structured objects survive both backends.
- SQLite primary key enforces upsert semantics the file store implements manually.
Deep Dive
When files are enough
- Single-user CLI agents and local prototypes.
- Low write rate and one process.
- Easy inspection and git-ignored local state.
Add file locking or accept last-write-wins if two processes may run.
When SQLite is the right default
- One app server or a desktop agent with moderate concurrency.
- Need SQL filters, indexes, and atomic upserts.
- Still zero external infrastructure.
Use WAL mode for better concurrent readers; keep the DB file off ephemeral container disks without a volume.
When Postgres (or similar) is justified
- Multi-instance services, multi-tenant SaaS, and managed backups.
- Row-level security, replicas, and operational tooling.
- Hybrid designs: relational profile fields +
pgvectorfor episodic retrieval (see vector section).
Schema choices that age well
| 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.
Interface boundaries
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.
Migration path
- JSON file for the happy-path demo.
- SQLite with identical interface for local multi-session use.
- Postgres for production HA.
- Optional vector index for semantic retrieval of episodic rows.
Export/import as JSON lines so you can move environments without proprietary lock-in.
Gotchas
- No user filter on list. Loading all rows into a shared agent is a privacy incident.
- Trusting model-supplied
user_id. Bind identity from the auth layer on the server. - Storing secrets in memory rows. Tokens and passwords need secret managers, not LTM.
- Treating transcripts as the store. Append-only logs are for audit; curated rows are for prompting.
- Ephemeral container filesystems. JSON/SQLite vanish without a volume or external DB.
- Unbounded value size. Cap JSON size or you will inject megabytes into context later.
- Missing delete-by-user. GDPR-style erasure is painful if you invent it after launch.
Alternatives
| 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 |
FAQs
Should every agent framework memory feature replace my table?
Use framework helpers for speed, but keep a clear schema and export path you control. Product requirements outlive any one library.
Is one row per preference better than one JSON document per user?
Rows per key make partial updates and deletes easier. A single document is fine at small scale if you version it carefully.
How do I store lists of episodic events?
Use an episodes namespace or table with append semantics (id, content, created_at) instead of overwriting a single key.
Can I keep short-term and long-term in one database?
Yes with different tables or TTLs. Do not mix session scratch state into the always-loaded profile namespace.
What indexes should I add first?
(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.
How should multi-tenant SaaS isolate memory?
Include tenant_id in the primary key and every query. Test with cross-tenant access attempts in CI.
Do I need migrations for JSON values?
Yes for required fields inside JSON. Validate on read and write with Pydantic models when shapes stabilize.
File store vs SQLite for a hackathon demo?
File store is fine for one process. SQLite costs minutes and teaches the production interface earlier.
Where should embeddings live?
Either a column on the episode row (pgvector) or a side index keyed by memory id. Keep the canonical text in the primary store.
How do backups interact with "forget this"?
Online delete is immediate; backups may retain data until expiry. Document that lag in your privacy policy and retention design.
Related
- Long-Term Memory Basics - first save/load loop
- Long-Term Memory: What Should Survive Between Agent Sessions - write policy
- Memory Retrieval: Surfacing the Right Fact at the Right Time - reading without flooding
- Architecture Guide: A Tiered Memory System for Production Agents - end-to-end tiers
- Vector Databases and RAG Basics - semantic side of stores
- Redis for Fast Short-Term Agent State - session-tier contrast
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.